diff --git a/controller/app/src/main/AndroidManifest.xml b/controller/app/src/main/AndroidManifest.xml
index f5fea14ec..be8ed6b79 100644
--- a/controller/app/src/main/AndroidManifest.xml
+++ b/controller/app/src/main/AndroidManifest.xml
@@ -194,6 +194,15 @@
android:value="Downloads offline library content through the co-located server REST engine." />
+
+
+
+
202 { state:"running" }
- * GET /k2go-api/code-assets/refresh/status -> { state, lines, downloaded, reused, failed, upToDate }
- * POST /k2go-api/code-assets/refresh/cancel -> { state:"cancelled" }
- * ============================================================================
- */
-package org.appdevforall.k2go.codeassets.data;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import org.appdevforall.k2go.config.BoxEndpoints;
-import org.json.JSONArray;
-import org.json.JSONObject;
-
-import java.io.ByteArrayOutputStream;
-import java.io.InputStream;
-import java.net.HttpURLConnection;
-import java.net.URL;
-import java.nio.charset.StandardCharsets;
-
-/**
- * Drives the box build-assets refresh to a terminal state. {@link #refresh} BLOCKS (poll loop), so
- * callers run it on an IO thread. The box job is detached, so a caller that dies mid-run does not stop
- * it: a later {@link #refresh} re-attaches by reading the same status (a running refresh is left alone).
- */
-public final class CodeAssetsRefreshClient {
-
- /** Terminal verdict of a refresh. */
- public enum Result { DONE, ERROR, CANCELLED }
-
- /** Streamed status-tail lines, for a live one-line view. Optional (pass null to ignore). */
- public interface Listener {
- void onLine(@NonNull String line);
- }
-
- private static final String REFRESH_URL = BoxEndpoints.API + "/code-assets/refresh";
- private static final String REFRESH_STATUS_URL = BoxEndpoints.API + "/code-assets/refresh/status";
- private static final String REFRESH_CANCEL_URL = BoxEndpoints.API + "/code-assets/refresh/cancel";
- private static final long POLL_MS = 2000L;
- private static final int MAX_POLL_ERRORS = 15; // ~30s of transient blips before giving up
- // The mirror pulls the release build set (hundreds of MB), so the run can take many minutes; cap
- // the wait so a wedged box job cannot block forever (the box job keeps running detached).
- private static final long MAX_WAIT_MS = 30 * 60 * 1000L;
-
- /** The last status-tail line handed to the listener, so a poll that did not advance stays quiet. */
- private String lastEmitted;
- private int lastDownloaded = -1, lastReused = -1, lastFailed = -1;
- private boolean lastUpToDate = false;
-
- /** Files downloaded in the last refresh; -1 if the box did not report it. */
- public int lastDownloaded() { return lastDownloaded; }
- /** Files reused unchanged in the last refresh; -1 if the box did not report it. */
- public int lastReused() { return lastReused; }
- /** Files the last refresh could not fetch or verify; -1 if the box did not report it. */
- public int lastFailed() { return lastFailed; }
- /** True when the published set was unchanged, so the refresh downloaded nothing. */
- public boolean lastUpToDate() { return lastUpToDate; }
-
- /**
- * Start the refresh if it is not already running, then poll to a terminal state. Each refresh is
- * intentional, so there is no "done" short-circuit: unless one is already running (re-attach), it
- * POSTs a fresh run. The box refresh is safe to re-run (it mirrors into a staging dir and only
- * swaps the live tree on success). Returns DONE when the box refresh finished, ERROR on an
- * unreachable box or timeout, CANCELLED if the user stopped it.
- */
- @NonNull
- public Result refresh(@Nullable Listener l) {
- String state = readState(l);
- if (!"running".equals(state)) {
- if (!post(REFRESH_URL)) return Result.ERROR;
- }
- return poll(l);
- }
-
- /** Ask the box to stop a running refresh (best-effort; a poll then reads "cancelled"). */
- public void cancel() {
- post(REFRESH_CANCEL_URL);
- }
-
- @NonNull
- private Result poll(@Nullable Listener l) {
- final long deadline = System.currentTimeMillis() + MAX_WAIT_MS;
- int pollErrors = 0;
- while (System.currentTimeMillis() < deadline) {
- try { Thread.sleep(POLL_MS); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); return Result.ERROR; }
- String s = readState(l);
- if (s == null) { if (++pollErrors > MAX_POLL_ERRORS) return Result.ERROR; continue; }
- pollErrors = 0;
- if ("done".equals(s)) return Result.DONE;
- if ("error".equals(s)) return Result.ERROR;
- if ("cancelled".equals(s)) return Result.CANCELLED;
- // "running" (or an unknown transient) -> keep polling.
- }
- return Result.ERROR; // timed out; the box job may still finish, a later refresh re-checks
- }
-
- /** POST a start/cancel endpoint; true if accepted (2xx) or already running (409). */
- private boolean post(@NonNull String url) {
- try {
- HttpURLConnection c = open("POST", url);
- int code = c.getResponseCode();
- c.disconnect();
- return (code >= 200 && code < 300) || code == 409;
- } catch (Exception e) {
- return false;
- }
- }
-
- /** GET the status endpoint -> the state string, streaming any new tail line; null on a read error. */
- @Nullable
- private String readState(@Nullable Listener l) {
- try {
- HttpURLConnection c = open("GET", REFRESH_STATUS_URL);
- int code = c.getResponseCode();
- boolean ok = code >= 200 && code < 400;
- String text = readAll(ok ? c.getInputStream() : c.getErrorStream());
- c.disconnect();
- if (!ok) return null;
- JSONObject j = new JSONObject(text.isEmpty() ? "{}" : text);
- if (j.has("downloaded")) lastDownloaded = j.optInt("downloaded", lastDownloaded);
- if (j.has("reused")) lastReused = j.optInt("reused", lastReused);
- if (j.has("failed")) lastFailed = j.optInt("failed", lastFailed);
- if (j.has("upToDate")) lastUpToDate = j.optBoolean("upToDate", lastUpToDate);
- if (l != null) {
- JSONArray lines = j.optJSONArray("lines");
- if (lines != null && lines.length() > 0) {
- String last = lines.optString(lines.length() - 1, "");
- if (!last.isEmpty() && !last.equals(lastEmitted)) { lastEmitted = last; l.onLine(last); }
- }
- }
- return j.optString("state", "");
- } catch (Exception e) {
- return null;
- }
- }
-
- private static HttpURLConnection open(String method, String urlStr) throws Exception {
- HttpURLConnection c = (HttpURLConnection) new URL(urlStr).openConnection();
- c.setUseCaches(false);
- c.setConnectTimeout(4000);
- c.setReadTimeout(4000);
- c.setRequestMethod(method);
- c.setRequestProperty("Accept", "application/json");
- return c;
- }
-
- private static String readAll(InputStream is) throws Exception {
- if (is == null) return "";
- ByteArrayOutputStream buf = new ByteArrayOutputStream();
- byte[] chunk = new byte[4096];
- int n;
- while ((n = is.read(chunk)) != -1) buf.write(chunk, 0, n);
- is.close();
- return buf.toString(StandardCharsets.UTF_8.name());
- }
-}
diff --git a/controller/app/src/main/java/org/appdevforall/k2go/codeassets/presentation/CodeAssetsDownloadService.java b/controller/app/src/main/java/org/appdevforall/k2go/codeassets/presentation/CodeAssetsDownloadService.java
new file mode 100644
index 000000000..7ed2cefb5
--- /dev/null
+++ b/controller/app/src/main/java/org/appdevforall/k2go/codeassets/presentation/CodeAssetsDownloadService.java
@@ -0,0 +1,98 @@
+/*
+ * ============================================================================
+ * Name : CodeAssetsDownloadService.java
+ * Author : AppDevForAll
+ * Copyright : Copyright (c) 2026 AppDevForAll
+ * Description : K2GO-443 / K2GO-449. Foreground shell for the Code on the Go build-assets download on
+ * the durable job engine (a single-item session, type "code-assets"). The generic service
+ * behavior (onStartCommand dispatch, notification, Host) lives in ContentDownloadServiceBase;
+ * this subclass only provides the module bits: the session singleton, the action strings,
+ * the notification text, and the START item (a sentinel, since the box runner reads the
+ * manifest itself). The heavy work (aria2, resume, verify, swap) runs on the box.
+ * ============================================================================
+ */
+package org.appdevforall.k2go.codeassets.presentation;
+
+import android.content.Context;
+import android.content.Intent;
+
+import androidx.core.content.ContextCompat;
+
+import org.appdevforall.k2go.R;
+import org.appdevforall.k2go.redesign.ContentDownloadServiceBase;
+import org.appdevforall.k2go.redesign.ContentDownloadSession;
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+public final class CodeAssetsDownloadService extends ContentDownloadServiceBase {
+
+ private static final String CHANNEL_ID = "code_assets_download_channel";
+ private static final int NOTIFICATION_ID = 7;
+
+ public static final String ACTION_START = "org.appdevforall.k2go.CODE_ASSETS_DOWNLOAD_START";
+ public static final String ACTION_PAUSE = "org.appdevforall.k2go.CODE_ASSETS_DOWNLOAD_PAUSE";
+ public static final String ACTION_RESUME = "org.appdevforall.k2go.CODE_ASSETS_DOWNLOAD_RESUME";
+ public static final String ACTION_CANCEL = "org.appdevforall.k2go.CODE_ASSETS_DOWNLOAD_CANCEL";
+ public static final String ACTION_RETRY = "org.appdevforall.k2go.CODE_ASSETS_DOWNLOAD_RETRY";
+
+ // The box runner reads the manifest itself and ignores the job items, so a single sentinel
+ // satisfies POST /code-assets/download (which requires a non-empty items/ids).
+ private static final String SENTINEL = "build-assets";
+
+ private static final ContentDownloadSession SESSION = new ContentDownloadSession("code-assets");
+
+ /** Adapts to the session listener; the UI passes {@code this::render}. */
+ public interface Listener { void onUpdate(); }
+
+ // ---- static API the UI observes (delegates to the shared session) --------------------------
+ public static boolean isRunning() { return SESSION.isRunning(); }
+ public static boolean isPaused() { return SESSION.isPaused(); }
+ public static boolean hasSession() { return SESSION.hasSession(); }
+ public static boolean isComplete() { return SESSION.isComplete(); }
+ public static boolean hasFailed() { return SESSION.hasFailed(); }
+ public static int percent() { return SESSION.percent(); }
+ public static long speed() { return SESSION.speed(); }
+ public static int reconnectAttempt() { return SESSION.reconnectAttempt(); }
+ public static int reconnectTotal() { return SESSION.reconnectTotal(); }
+ public static void setListener(Listener l) { SESSION.setListener(l == null ? null : l::onUpdate); }
+
+ public static void start(Context ctx) { send(ctx, ACTION_START); }
+ public static void pause(Context ctx) { if (SESSION.isRunning()) send(ctx, ACTION_PAUSE); }
+ public static void resume(Context ctx) { send(ctx, ACTION_RESUME); }
+ public static void cancel(Context ctx) { send(ctx, ACTION_CANCEL); }
+
+ /** Re-queue the failed item and resume (the box resumes the partial via aria2 --continue). */
+ public static void retry(Context ctx) {
+ if (SESSION.requeueFailed() && !SESSION.isRunning()) send(ctx, ACTION_RETRY);
+ }
+
+ public static void finishSession() { SESSION.purge(); }
+
+ private static void send(Context ctx, String action) {
+ ContextCompat.startForegroundService(ctx,
+ new Intent(ctx, CodeAssetsDownloadService.class).setAction(action));
+ }
+
+ // ---- per-module hooks for ContentDownloadServiceBase ---------------------------------------
+ @Override protected ContentDownloadSession session() { return SESSION; }
+ @Override protected int notificationId() { return NOTIFICATION_ID; }
+ @Override protected String channelId() { return CHANNEL_ID; }
+ @Override protected String channelName() { return getString(R.string.k2go_card_code_assets); }
+ @Override protected String notifTitle() { return getString(R.string.k2go_code_assets_updating); }
+ @Override protected String notifText() { return getString(R.string.k2go_card_code_assets); }
+ @Override protected String actionPause() { return ACTION_PAUSE; }
+ @Override protected String actionResume() { return ACTION_RESUME; }
+ @Override protected String actionCancel() { return ACTION_CANCEL; }
+ @Override protected String actionRetry() { return ACTION_RETRY; }
+
+ @Override
+ protected void beginFromIntent(Intent intent) {
+ String[] keys = { "code-assets" };
+ String[] labels = { getString(R.string.k2go_card_code_assets) };
+ long[] sizes = { 0L }; // count-based: the UI shows the item percent directly
+ JSONObject[] bodies = new JSONObject[1];
+ try { bodies[0] = new JSONObject().put("ids", new JSONArray().put(SENTINEL)); }
+ catch (Exception e) { bodies[0] = new JSONObject(); }
+ SESSION.begin(keys, labels, sizes, bodies);
+ }
+}
diff --git a/controller/app/src/main/java/org/appdevforall/k2go/codeassets/presentation/CodeAssetsRefresh.java b/controller/app/src/main/java/org/appdevforall/k2go/codeassets/presentation/CodeAssetsRefresh.java
index 3975875a3..b3d5baf2e 100644
--- a/controller/app/src/main/java/org/appdevforall/k2go/codeassets/presentation/CodeAssetsRefresh.java
+++ b/controller/app/src/main/java/org/appdevforall/k2go/codeassets/presentation/CodeAssetsRefresh.java
@@ -3,17 +3,17 @@
* Name : CodeAssetsRefresh.java
* Author : AppDevForAll
* Copyright : Copyright (c) 2026 AppDevForAll
- * Description : K2GO-437. The single "Update build assets" flow, shared by the module detail button and
- * the module action sheet row so neither duplicates it. Modeled on AddonsRefresh: it gates
- * like the dashboard update (needs internet, then metered consent), shows minimal inline
- * progress (a description, a live one-line output tail, an indeterminate bar and a Cancel)
- * injected right after the trigger view, runs the box refresh on an IO thread, and reports
- * the outcome in a snackbar.
+ * Description : K2GO-437 / K2GO-443. The single "Update build assets" flow, shared by the module detail
+ * button and the module action sheet row. It gates like the dashboard update (needs
+ * internet, then metered consent), then drives the durable job engine through
+ * CodeAssetsDownloadService: a determinate progress bar (percent + speed) with Pause /
+ * Resume and Cancel, injected right after the trigger view. The download runs in a
+ * foreground service and on the box, so it survives this view going away: re-opening the
+ * sheet re-attaches to the running session.
*
- * Lifecycle: there is NO persistent app-side state. The box refresh job is detached
- * (setsid), so a host that goes away mid-run just drops the UI updates (guarded by
- * View.isAttachedToWindow()); the box finishes on its own and the tree is swapped in only
- * on success. The only state is the box's own status/pid files, which the box manages.
+ * Lifecycle: no persistent app-side state here. Pause/resume/cancel state lives in the
+ * session + the box job; this view only observes it and re-renders. A terminal state
+ * (done / failed / cancelled) removes the inline UI and reports it in a snackbar.
* ============================================================================
*/
package org.appdevforall.k2go.codeassets.presentation;
@@ -35,8 +35,7 @@
import com.google.android.material.progressindicator.LinearProgressIndicator;
import org.appdevforall.k2go.R;
-import org.appdevforall.k2go.codeassets.data.CodeAssetsRefreshClient;
-import org.appdevforall.k2go.util.AppExecutors;
+import org.appdevforall.k2go.util.ByteFormatter;
import org.appdevforall.k2go.util.Snackbars;
public final class CodeAssetsRefresh {
@@ -44,7 +43,7 @@ public final class CodeAssetsRefresh {
private CodeAssetsRefresh() {}
/**
- * Gate (internet, then metered consent) then run the refresh with progress injected right after
+ * Gate (internet, then metered consent) then run the update with progress injected right after
* {@code trigger}. The trigger stays in place (only disabled) as a visible anchor for the snackbar.
*/
public static void start(@NonNull Activity act, @NonNull View trigger) {
@@ -59,36 +58,36 @@ public static void start(@NonNull Activity act, @NonNull View trigger) {
private static void run(@NonNull View trigger) {
final ViewGroup parent = (ViewGroup) trigger.getParent();
if (parent == null || !trigger.isAttachedToWindow()) return; // host went away during the gate
- final Context ctx = trigger.getContext();
+ final Context ctx = trigger.getContext().getApplicationContext();
final Handler main = new Handler(Looper.getMainLooper());
- final float d = ctx.getResources().getDisplayMetrics().density;
+ final float d = trigger.getResources().getDisplayMetrics().density;
final int side = Math.round(20 * d);
- final LinearLayout progress = new LinearLayout(ctx);
+ final LinearLayout progress = new LinearLayout(trigger.getContext());
progress.setOrientation(LinearLayout.VERTICAL);
LinearLayout.LayoutParams plp = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
plp.leftMargin = side; plp.rightMargin = side; plp.topMargin = Math.round(8 * d);
progress.setLayoutParams(plp);
- final TextView label = new TextView(ctx);
+ final TextView label = new TextView(trigger.getContext());
label.setText(R.string.k2go_code_assets_updating);
label.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall);
- label.setTextColor(ContextCompat.getColor(ctx, R.color.k2go_muted));
+ label.setTextColor(ContextCompat.getColor(trigger.getContext(), R.color.k2go_muted));
progress.addView(label);
- final TextView liveLine = new TextView(ctx);
- liveLine.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall);
- liveLine.setTextColor(ContextCompat.getColor(ctx, R.color.k2go_muted));
- liveLine.setMaxLines(1);
- liveLine.setEllipsize(TextUtils.TruncateAt.END);
- LinearLayout.LayoutParams llp = new LinearLayout.LayoutParams(
+ final TextView statusLine = new TextView(trigger.getContext());
+ statusLine.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall);
+ statusLine.setTextColor(ContextCompat.getColor(trigger.getContext(), R.color.k2go_muted));
+ statusLine.setMaxLines(1);
+ statusLine.setEllipsize(TextUtils.TruncateAt.END);
+ LinearLayout.LayoutParams slp = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
- llp.topMargin = Math.round(2 * d);
- liveLine.setLayoutParams(llp);
- progress.addView(liveLine);
+ slp.topMargin = Math.round(2 * d);
+ statusLine.setLayoutParams(slp);
+ progress.addView(statusLine);
- final LinearLayout barLine = new LinearLayout(ctx);
+ final LinearLayout barLine = new LinearLayout(trigger.getContext());
barLine.setOrientation(LinearLayout.HORIZONTAL);
barLine.setGravity(Gravity.CENTER_VERTICAL);
LinearLayout.LayoutParams barLineLp = new LinearLayout.LayoutParams(
@@ -96,54 +95,103 @@ private static void run(@NonNull View trigger) {
barLineLp.topMargin = Math.round(4 * d);
barLine.setLayoutParams(barLineLp);
- final LinearProgressIndicator bar = new LinearProgressIndicator(ctx);
+ final LinearProgressIndicator bar = new LinearProgressIndicator(trigger.getContext());
bar.setIndeterminate(true);
LinearLayout.LayoutParams blp = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f);
bar.setLayoutParams(blp);
barLine.addView(bar);
- final TextView cancel = new TextView(ctx);
- cancel.setText(R.string.k2go_dash_cancel);
- cancel.setAllCaps(true);
- cancel.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelLarge);
- cancel.setTextColor(ContextCompat.getColor(ctx, R.color.k2go_teal));
- int hp = Math.round(12 * d), vp = Math.round(6 * d);
- cancel.setPadding(hp, vp, hp, vp);
- barLine.addView(cancel);
+ final int hp = Math.round(12 * d), vp = Math.round(6 * d);
+ final TextView pauseBtn = new TextView(trigger.getContext());
+ pauseBtn.setText(R.string.k2go_dl_pause);
+ pauseBtn.setAllCaps(true);
+ pauseBtn.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelLarge);
+ pauseBtn.setTextColor(ContextCompat.getColor(trigger.getContext(), R.color.k2go_teal));
+ pauseBtn.setPadding(hp, vp, hp, vp);
+ barLine.addView(pauseBtn);
+
+ final TextView cancelBtn = new TextView(trigger.getContext());
+ cancelBtn.setText(R.string.k2go_dash_cancel);
+ cancelBtn.setAllCaps(true);
+ cancelBtn.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelLarge);
+ cancelBtn.setTextColor(ContextCompat.getColor(trigger.getContext(), R.color.k2go_teal));
+ cancelBtn.setPadding(hp, vp, hp, vp);
+ barLine.addView(cancelBtn);
progress.addView(barLine);
parent.addView(progress, parent.indexOfChild(trigger) + 1);
- trigger.setEnabled(false); // stays in place as an anchor; re-enabled when the refresh settles
+ trigger.setEnabled(false); // stays in place as an anchor; re-enabled when the update settles
- cancel.setOnClickListener(cv -> {
- cancel.setEnabled(false);
- label.setText(R.string.k2go_code_assets_update_cancelling);
- AppExecutors.get().io().execute(() -> new CodeAssetsRefreshClient().cancel());
+ pauseBtn.setOnClickListener(v -> {
+ if (CodeAssetsDownloadService.isPaused()) CodeAssetsDownloadService.resume(ctx);
+ else CodeAssetsDownloadService.pause(ctx);
});
-
- AppExecutors.get().io().execute(() -> {
- final CodeAssetsRefreshClient client = new CodeAssetsRefreshClient();
- final CodeAssetsRefreshClient.Result r = client.refresh(rawLine -> {
- final String shown = rawLine.trim();
- main.post(() -> { if (liveLine.isAttachedToWindow()) liveLine.setText(shown); });
- });
- final int failed = client.lastFailed();
- final boolean upToDate = client.lastUpToDate();
- main.post(() -> {
- if (!trigger.isAttachedToWindow()) return;
- parent.removeView(progress);
- trigger.setEnabled(true);
- Snackbars.make(trigger, ctx.getString(messageFor(r, failed, upToDate))).show();
- });
+ cancelBtn.setOnClickListener(v -> {
+ cancelBtn.setEnabled(false);
+ CodeAssetsDownloadService.cancel(ctx);
});
+
+ // Clear the session listener the moment this view leaves the window (sheet dismissed), so the
+ // static session never keeps a destroyed Activity alive while a download runs on. Deterministic:
+ // it does not wait for the next session event (a paused download emits none).
+ final View.OnAttachStateChangeListener detach = new View.OnAttachStateChangeListener() {
+ @Override public void onViewAttachedToWindow(@NonNull View v) {}
+ @Override public void onViewDetachedFromWindow(@NonNull View v) {
+ CodeAssetsDownloadService.setListener(null);
+ }
+ };
+
+ final Runnable[] render = new Runnable[1];
+ render[0] = () -> {
+ if (!trigger.isAttachedToWindow()) { CodeAssetsDownloadService.setListener(null); return; }
+ final boolean running = CodeAssetsDownloadService.isRunning();
+ // hasFailed() BEFORE isComplete(): isComplete() is true for an all-FAILED session too
+ // (FAILED is "not in progress"), so a failed run must be caught first.
+ if (!running && CodeAssetsDownloadService.hasFailed()) {
+ terminal(trigger, parent, progress, detach, R.string.k2go_code_assets_update_failed);
+ return;
+ }
+ if (CodeAssetsDownloadService.isComplete()) {
+ terminal(trigger, parent, progress, detach, R.string.k2go_code_assets_update_done);
+ return;
+ }
+ if (!CodeAssetsDownloadService.hasSession()) { // cancelled (purged)
+ terminal(trigger, parent, progress, detach, R.string.k2go_code_assets_update_cancelled);
+ return;
+ }
+ final boolean paused = CodeAssetsDownloadService.isPaused();
+ final int pct = CodeAssetsDownloadService.percent();
+ bar.setIndeterminate(pct < 0);
+ if (pct >= 0) bar.setProgressCompat(pct, true);
+ pauseBtn.setText(paused ? R.string.k2go_dl_resume : R.string.k2go_dl_pause);
+ if (CodeAssetsDownloadService.reconnectAttempt() > 0) {
+ statusLine.setText(R.string.k2go_retrying);
+ } else if (paused) {
+ statusLine.setText(R.string.k2go_dl_paused);
+ } else {
+ final long spd = CodeAssetsDownloadService.speed();
+ statusLine.setText(spd > 0
+ ? ByteFormatter.toHuman(spd) + "/s"
+ : trigger.getContext().getString(R.string.k2go_code_assets_updating));
+ }
+ };
+
+ trigger.addOnAttachStateChangeListener(detach);
+ // publish() already posts to the main thread, so the listener runs on main: wire it directly.
+ CodeAssetsDownloadService.setListener(render[0]::run);
+ CodeAssetsDownloadService.start(ctx);
+ main.post(render[0]); // initial paint
}
- /** Map the refresh outcome to a user message covering every state. */
- private static int messageFor(CodeAssetsRefreshClient.Result r, int failed, boolean upToDate) {
- if (r == CodeAssetsRefreshClient.Result.CANCELLED) return R.string.k2go_code_assets_update_cancelled;
- if (r != CodeAssetsRefreshClient.Result.DONE) return R.string.k2go_code_assets_update_failed; // box unreachable
- if (failed > 0) return R.string.k2go_code_assets_update_some_failed; // some files could not be fetched
- if (upToDate) return R.string.k2go_code_assets_update_none;
- return R.string.k2go_code_assets_update_done;
+ private static void terminal(@NonNull View trigger, @NonNull ViewGroup parent, @NonNull View progress,
+ @NonNull View.OnAttachStateChangeListener detach, int msgRes) {
+ CodeAssetsDownloadService.setListener(null);
+ trigger.removeOnAttachStateChangeListener(detach);
+ if (progress.getParent() == parent) parent.removeView(progress);
+ trigger.setEnabled(true);
+ if (trigger.isAttachedToWindow()) {
+ Snackbars.make(trigger, trigger.getContext().getString(msgRes)).show();
+ }
+ CodeAssetsDownloadService.finishSession();
}
}
diff --git a/controller/app/src/main/java/org/appdevforall/k2go/redesign/ContentDownloadServiceBase.java b/controller/app/src/main/java/org/appdevforall/k2go/redesign/ContentDownloadServiceBase.java
new file mode 100644
index 000000000..97ef73895
--- /dev/null
+++ b/controller/app/src/main/java/org/appdevforall/k2go/redesign/ContentDownloadServiceBase.java
@@ -0,0 +1,112 @@
+/*
+ * ============================================================================
+ * Name : ContentDownloadServiceBase.java
+ * Author : AppDevForAll
+ * Copyright : Copyright (c) 2026 AppDevForAll
+ * Description : K2GO-449. Shared foreground-service shell for content downloads on the durable job
+ * engine. Holds the instance behavior every content service copied: the onStartCommand
+ * dispatch of pause/resume/cancel/retry, the notification + channel, and the
+ * ContentDownloadSession.Host plumbing. Per-module bits come from abstract hooks (the
+ * module's session, its action strings, the START item-building, and the notification
+ * text). The per-type ContentDownloadSession singleton stays owned by the subclass, so
+ * this adds no new state. CodeAssetsDownloadService adopts it first; ZimDownloadService /
+ * BooksDownloadService / maps migrate onto it in later, separately reviewed slices.
+ * ============================================================================
+ */
+package org.appdevforall.k2go.redesign;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.app.Service;
+import android.content.Intent;
+import android.os.Build;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+
+import androidx.annotation.Nullable;
+import androidx.core.app.NotificationCompat;
+
+import org.appdevforall.k2go.R;
+
+public abstract class ContentDownloadServiceBase extends Service implements ContentDownloadSession.Host {
+
+ private final Handler main = new Handler(Looper.getMainLooper());
+
+ // ---- per-module hooks ----------------------------------------------------------------------
+ /** The module's session singleton (the subclass owns the static instance). */
+ protected abstract ContentDownloadSession session();
+ protected abstract int notificationId();
+ protected abstract String channelId();
+ protected abstract String channelName();
+ protected abstract String notifTitle();
+ protected abstract String notifText();
+ protected abstract String actionPause();
+ protected abstract String actionResume();
+ protected abstract String actionCancel();
+ protected abstract String actionRetry();
+ /** Build and begin a fresh session from the start intent (the subclass calls session().begin(...)). */
+ protected abstract void beginFromIntent(Intent intent);
+
+ @Override public void onCreate() { super.onCreate(); createChannel(); session().attach(this); }
+ @Nullable @Override public IBinder onBind(Intent intent) { return null; }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ session().attach(this);
+ final String a = intent != null ? intent.getAction() : null;
+ if (a != null) {
+ if (a.equals(actionCancel())) { session().cancelAndPurge(); return START_NOT_STICKY; }
+ if (a.equals(actionPause())) { session().pauseActive(); return START_NOT_STICKY; }
+ if (a.equals(actionResume())) { session().resumeActive(); return START_NOT_STICKY; }
+ }
+ if (session().isRunning()) return START_NOT_STICKY; // duplicate start/retry: ignore
+
+ if (a != null && a.equals(actionRetry())) {
+ if (!session().hasSession()) { stopSelf(); return START_NOT_STICKY; }
+ startForeground(notificationId(), buildNotification());
+ session().resumeQueue();
+ } else { // START (any other action): a fresh session built by the subclass
+ startForeground(notificationId(), buildNotification());
+ beginFromIntent(intent);
+ }
+ return START_NOT_STICKY;
+ }
+
+ // ---- ContentDownloadSession.Host (generic) -------------------------------------------------
+ @Override public void notify(String label) {
+ if (!session().isRunning()) return;
+ NotificationManager m = getSystemService(NotificationManager.class);
+ if (m != null) m.notify(notificationId(), buildNotification());
+ }
+
+ @Override public void stop() { main.post(() -> { stopForeground(true); stopSelf(); }); }
+
+ @Override public void onItemDone(String key) { /* default: no wishlist; a subclass may override */ }
+
+ private void createChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ NotificationChannel channel = new NotificationChannel(
+ channelId(), channelName(), NotificationManager.IMPORTANCE_LOW);
+ NotificationManager manager = getSystemService(NotificationManager.class);
+ if (manager != null) manager.createNotificationChannel(channel);
+ }
+ }
+
+ protected Notification buildNotification() {
+ Intent cancel = new Intent(this, getClass()).setAction(actionCancel());
+ PendingIntent cancelIntent = PendingIntent.getService(this, 1, cancel,
+ PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
+ return new NotificationCompat.Builder(this, channelId())
+ .setContentTitle(notifTitle())
+ .setContentText(notifText())
+ .setSmallIcon(android.R.drawable.stat_sys_download)
+ .setOngoing(true)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setOnlyAlertOnce(true)
+ .addAction(0, getString(R.string.k2go_dash_cancel), cancelIntent)
+ .build();
+ }
+}
diff --git a/controller/app/src/main/java/org/appdevforall/k2go/redesign/ModuleActionSheet.java b/controller/app/src/main/java/org/appdevforall/k2go/redesign/ModuleActionSheet.java
index e63322546..c05077c2b 100644
--- a/controller/app/src/main/java/org/appdevforall/k2go/redesign/ModuleActionSheet.java
+++ b/controller/app/src/main/java/org/appdevforall/k2go/redesign/ModuleActionSheet.java
@@ -223,19 +223,13 @@ public static void show(Activity act, String endpoint, String title, int iconRes
});
});
}
- // K2GO-99: an installed add-ons gallery offers a live refresh here (the module action
- // menu). No status gate (installed means there is a gallery); the shared flow gates
- // internet, then runs the refresh IN the sheet (it does NOT dismiss).
- if ("code_addons".equals(key)) {
- content.addView(row(ctx, R.drawable.ic_refresh,
- act.getString(R.string.k2go_code_addons_update), Emphasis.ACCENT, null, false,
- v -> org.appdevforall.k2go.addons.presentation.AddonsRefresh.start(act, v)));
- }
- // K2GO-437: an installed build-assets tree offers the same live refresh here.
- if ("code_assets".equals(key)) {
- content.addView(row(ctx, R.drawable.ic_refresh,
- act.getString(R.string.k2go_code_assets_update), Emphasis.ACCENT, null, false,
- v -> org.appdevforall.k2go.codeassets.presentation.CodeAssetsRefresh.start(act, v)));
+ // K2GO-449: an installed content module's update action comes from the ModuleActions
+ // registry (code_addons, code_assets, ...), so this sheet no longer carries one if per
+ // module. Forgejo's repos action stays special above (status-gated).
+ ModuleActions.InstalledAction ia = ModuleActions.installed(key);
+ if (ia != null) {
+ content.addView(row(ctx, ia.iconRes, act.getString(ia.labelRes),
+ Emphasis.ACCENT, null, false, v -> ia.handler.run(act, v)));
}
break;
case SCHEDULED: {
diff --git a/controller/app/src/main/java/org/appdevforall/k2go/redesign/ModuleActions.java b/controller/app/src/main/java/org/appdevforall/k2go/redesign/ModuleActions.java
new file mode 100644
index 000000000..417f28a32
--- /dev/null
+++ b/controller/app/src/main/java/org/appdevforall/k2go/redesign/ModuleActions.java
@@ -0,0 +1,59 @@
+/*
+ * ============================================================================
+ * Name : ModuleActions.java
+ * Author : AppDevForAll
+ * Copyright : Copyright (c) 2026 AppDevForAll
+ * Description : K2GO-449. One place that declares a content module's "installed" action (the update
+ * action shown once the module is installed), so ModuleActionSheet and
+ * ModuleDetailFragment iterate it instead of each carrying a per-module if block. A new
+ * content module registers an entry here; the shared UI files stop changing per module.
+ * Static config only, no runtime state. Forgejo's repos action stays special for now (it
+ * is status-gated / async), so it is not in this registry yet.
+ * ============================================================================
+ */
+package org.appdevforall.k2go.redesign;
+
+import android.app.Activity;
+import android.view.View;
+
+import androidx.annotation.Nullable;
+
+import org.appdevforall.k2go.R;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public final class ModuleActions {
+
+ private ModuleActions() {}
+
+ /** Runs a module's installed action, anchored to a view (for the snackbar / inline progress). */
+ public interface Handler { void run(Activity act, View anchor); }
+
+ /** The update action a module offers once installed: a labeled, iconed row that runs {@link #handler}. */
+ public static final class InstalledAction {
+ public final int labelRes;
+ public final int iconRes;
+ public final Handler handler;
+ InstalledAction(int labelRes, int iconRes, Handler handler) {
+ this.labelRes = labelRes; this.iconRes = iconRes; this.handler = handler;
+ }
+ }
+
+ // Keyed by the module's YAML key (ModuleCards.Card.key()).
+ private static final Map INSTALLED = new HashMap<>();
+ static {
+ INSTALLED.put("code_addons", new InstalledAction(
+ R.string.k2go_code_addons_update, R.drawable.ic_refresh,
+ (act, v) -> org.appdevforall.k2go.addons.presentation.AddonsRefresh.start(act, v)));
+ INSTALLED.put("code_assets", new InstalledAction(
+ R.string.k2go_code_assets_update, R.drawable.ic_refresh,
+ (act, v) -> org.appdevforall.k2go.codeassets.presentation.CodeAssetsRefresh.start(act, v)));
+ }
+
+ /** The installed action for {@code key}, or null when the module has none (or is handled specially). */
+ @Nullable
+ public static InstalledAction installed(@Nullable String key) {
+ return key == null ? null : INSTALLED.get(key);
+ }
+}
diff --git a/controller/app/src/main/java/org/appdevforall/k2go/redesign/ModuleDetailFragment.java b/controller/app/src/main/java/org/appdevforall/k2go/redesign/ModuleDetailFragment.java
index 9a12eb860..7577040e5 100644
--- a/controller/app/src/main/java/org/appdevforall/k2go/redesign/ModuleDetailFragment.java
+++ b/controller/app/src/main/java/org/appdevforall/k2go/redesign/ModuleDetailFragment.java
@@ -112,8 +112,6 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c
final com.google.android.material.checkbox.MaterialCheckBox forgejoRepos =
root.findViewById(R.id.k2go_moddet_forgejo_repos);
final boolean isForgejo = "forgejo".equals(c.key());
- final boolean isCodeAddons = "code_addons".equals(c.key());
- final boolean isCodeAssets = "code_assets".equals(c.key());
// K2GO-417: the repos opt-in is an INSTALL-TIME choice, so it is shown ONLY in the installable
// branch below (default-checked in the layout). Once the module is installed it stays GONE:
// toggling it would do nothing (roles are not reinstalled from here, and unchecking cannot remove
@@ -199,21 +197,13 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c
});
});
}
- if (isCodeAddons) {
- // K2GO-99: an installed add-ons gallery offers a live refresh (re-mirror the
- // published gallery). Minimal inline progress via the shared flow; no status gate
- // (unlike Forgejo there is no sub-state: installed means there is a gallery).
- installNowBtn.setText(R.string.k2go_code_addons_update);
- installNowBtn.setOnClickListener(v ->
- org.appdevforall.k2go.addons.presentation.AddonsRefresh.start(requireActivity(), installNowBtn));
- installNowBtn.setVisibility(View.VISIBLE);
- }
- if (isCodeAssets) {
- // K2GO-437: an installed build-assets tree offers the same live refresh (re-mirror
- // the release build set). Shared flow, like the add-ons update.
- installNowBtn.setText(R.string.k2go_code_assets_update);
- installNowBtn.setOnClickListener(v ->
- org.appdevforall.k2go.codeassets.presentation.CodeAssetsRefresh.start(requireActivity(), installNowBtn));
+ // K2GO-449: an installed content module's update action comes from the ModuleActions
+ // registry (code_addons, code_assets, ...), so this fragment no longer carries one if
+ // per module. Forgejo's repos action stays special above (status-gated).
+ ModuleActions.InstalledAction ia = ModuleActions.installed(c.key());
+ if (ia != null) {
+ installNowBtn.setText(ia.labelRes);
+ installNowBtn.setOnClickListener(v -> ia.handler.run(requireActivity(), installNowBtn));
installNowBtn.setVisibility(View.VISIBLE);
}
return; // a module cannot be uninstalled or reinstalled here (repos action aside)
diff --git a/controller/docs/ADR-content-module-extraction.md b/controller/docs/ADR-content-module-extraction.md
new file mode 100644
index 000000000..8473d3b9c
--- /dev/null
+++ b/controller/docs/ADR-content-module-extraction.md
@@ -0,0 +1,78 @@
+# ADR: Extract the shared content-module plumbing (K2GO-449)
+
+Status: Proposed
+
+## Context
+
+Content modules (Forgejo, Code on the Go add-ons, Code on the Go build assets)
+each copy the same plumbing:
+
+- A foreground download service per module. `ZimDownloadService`,
+ `BooksDownloadService`, and now `CodeAssetsDownloadService` (K2GO-443) share one
+ shell: a static `ContentDownloadSession`, static getters delegating to it, static
+ start/pause/resume/cancel/retry, `onStartCommand` action dispatch, the
+ notification, and the `ContentDownloadSession.Host` methods. Only the type, the
+ START item-building, and the notification text differ.
+- A per-module block in `redesign/ModuleActionSheet` and
+ `redesign/ModuleDetailFragment`, keyed by `"".equals(key)` (the READY
+ action row and the installed-state button). It grows with every module.
+- For the wrapper updaters, a near-identical refresh route trio in dash-node and a
+ clone/baked mirror-path resolver duplicated across `tools/*-refresh.sh`
+ (K2GO-440 added the resolver to a second wrapper; K2GO-443 a third would follow).
+
+The K2GO-443 build-assets work added the third download service and the third
+resolver. Per the design-coherence rule, several copies around one concern is a
+redesign signal, not another patch.
+
+## Decision
+
+Extract the shared plumbing in small, separately reviewed slices, newest first,
+without destabilizing the tested services.
+
+1. Shared download-service base: an abstract `ContentDownloadServiceBase` (a
+ `Service` implementing `ContentDownloadSession.Host`) that holds the generic
+ instance behavior: `onStartCommand` dispatch of pause/resume/cancel/retry, the
+ notification scaffolding, the channel, and the default `Host` methods. Abstract
+ hooks give the per-module bits: the module's `ContentDownloadSession`, the
+ START item-building (keys/labels/sizes/bodies), and the notification text/
+ contentIntent. `CodeAssetsDownloadService` adopts it FIRST (it is the newest and
+ smallest, and not yet depended on). `ZimDownloadService`, `BooksDownloadService`
+ and the maps service migrate onto it in later slices, each reviewed on its own,
+ so a ZIM regression is never bundled with the extraction.
+ - The slice-2a listener-leak fix (clear on view-detach) and the terminal-order
+ rule live in the shared UI driver, so every module gets them once.
+2. Generic module-action registration: a small `ModuleActions` registry (per
+ module: the action label, its gate, and its handler) that `ModuleActionSheet`
+ and `ModuleDetailFragment` iterate, so neither carries a per-module `if`. Adding
+ a module registers an entry; the shared UI files stop changing per module.
+3. Shared refresh-wrapper lib: one sourced `tools/lib/updater-refresh.sh` with the
+ clone/baked mirror resolver (using `[ -s ]`, not `[ -f ]`, per the K2GO-440
+ review) and the common staging/swap body, for the wrappers that remain until
+ their module migrates to the job engine.
+
+## Migration order (safe, incremental)
+
+1. `ContentDownloadServiceBase` + `CodeAssetsDownloadService` adopts it. (No tested
+ service touched.)
+2. `ZimDownloadService` / `BooksDownloadService` / maps adopt the base, one slice
+ each, each reviewed and device-checked.
+3. `ModuleActions` registry; `ModuleActionSheet` / `ModuleDetailFragment` iterate it.
+4. Add-ons and Forgejo move to the durable job engine (K2GO-443), now without new
+ clones. Retire each module's refresh route trio + wrapper as it migrates.
+
+## Lifecycle / risk
+
+- The per-type `ContentDownloadSession` singleton stays the single owner of
+ progress/pause/reconnect state; the base adds no new state.
+- Each tested-service migration is its own reviewed, device-checked slice; a
+ regression is never bundled with the extraction itself.
+- Back-compat: a module's old wrapper route stays until that module fully migrates
+ and shipped APKs that call it have aged out.
+
+## Consequences
+
+- A new content module becomes config + module-specific code; the shared UI files
+ and the service shell are untouched.
+- The build-assets service clone (K2GO-443, transitional) is absorbed by step 1.
+- No behavior change for users; this is structural. Each slice is reviewable and
+ reversible on its own.
diff --git a/controller/docs/ADR-updater-progress-resilience.md b/controller/docs/ADR-updater-progress-resilience.md
new file mode 100644
index 000000000..e984e4739
--- /dev/null
+++ b/controller/docs/ADR-updater-progress-resilience.md
@@ -0,0 +1,117 @@
+# ADR: Standardize updater progress and network resilience (K2GO-443, K2GO-383)
+
+Status: Proposed
+
+## Context
+
+The box has four updaters. Three are content refreshes driven by dash-node
+wrappers (Forgejo repos, Code on the Go add-ons, Code on the Go build assets);
+the fourth is the dash-node rebuild (self-update). Today all four are minimalist:
+an indeterminate spinner plus, at best, the last log line and final counts. None
+of the three content refreshes gives per-item percent, speed, pause, resume, or
+retry.
+
+The problem to solve is NOT only showing information. It is control and
+resilience against network errors: a network change (Wi-Fi to data and back) or a
+dropped link must not break a large download or leave an update half-applied. A
+flaky origin is one more possibility to absorb (an R2 move has been floated as a
+candidate; nothing is established yet, and this ADR does not assume it). Without
+pause/resume/retry/renegotiate, finishing a large transfer (the rootfs was the
+proof) is close to impossible. So pause, resume and retry are in the
+specification, not optional polish.
+
+This is a strong reuse situation. The proven mechanism already exists:
+
+- dash-node durable job engine (`sockets/jobs.ts`): per-job `phase`, `percent`
+ (-1 = indeterminate), `speed` (bytes/s), `detail`, structured status over the
+ generic `/:type/*` REST surface, plus pause / resume / retry / cancel.
+- aria2c runners that download with resilience: `sockets/kiwix.exec.ts` and
+ `sockets/maps-base.exec.ts`. Canonical aria2 args (`--continue`,
+ `--check-integrity`, `--split`, `--max-tries=5`, retry-wait, timeouts) and an
+ outer `withRetry` loop that re-runs aria2 on a FULL interface loss (exit 19
+ DNS) and resumes via `--continue` across a mobile handoff. Pause keeps the
+ partial + `.aria2`; cancel prunes it.
+- App side: `RestContentClient` (type-parametric: percent/speed, pause/resume/
+ cancel, start-or-attach, "Reconnecting n/5"), the `download/domain/*` set
+ (`Aria2ProgressLine`, `DownloadEta`, `DownloadRetryPolicy`, `DownloadVerifier`),
+ `ZimDownloadService` (foreground shell), `DownloadStateViewModel`, and
+ `Aria2Manager` (the app's own aria2 downloader, sharing the canonical args).
+
+The canonical aria2 flag set is already mirrored between the dash-node runners
+and `Aria2Manager.java` (documented in `kiwix.exec.ts`).
+
+## Decision
+
+Standardize the four updaters on one progress contract and one app component,
+and move the downloading updaters onto the existing aria2 job engine rather than
+building anything new.
+
+1. Content downloads (add-ons, build assets): register a durable job-engine
+ runner per type (template: `kiwix.exec.ts` / `maps-base.exec.ts`). aria2c does
+ the download (percent + speed + `--continue` resume + the Wi-Fi-drop outer
+ retry loop); the existing mirror keeps ONLY verify (md5 / sha256, Cloudflare
+ strip, catalog rewrite) and the finalize (generate the browse page, atomic
+ staging swap). This replaces the mirror's `urllib` per-file download. The box
+ gains pause / resume / retry / cancel and network-change resilience for free,
+ identical to maps / kiwix / books / kolibri.
+2. Forgejo (git): wrap the existing `refresh_forgejo` orchestration in a
+ job-engine runner that reports per-repo progress (git `fetch --progress`
+ percent where available, else repo N of M) and retries a failed repo. git has
+ no aria2-style mid-transfer pause/resume; retry-per-repo is the realistic
+ control and is enough (each repo op is small, fast-forward or a side ref).
+3. Dashboard rebuild (K2GO-383): this is a compile (yarn build), not a download,
+ so aria2 does not apply. It keeps its own progress but gains percent + ETA +
+ persistence over its existing phases (building / promoting) and log. No
+ pause/resume (a compile is not resumable); cancel already exists (ADFA-5333).
+4. App: replace the bespoke inline progress in `AddonsRefresh` /
+ `CodeAssetsRefresh` with the shared download UI already used by the job-engine
+ modules (`RestContentClient` + `ZimDownloadService` + the `download/domain`
+ progress/ETA parsing), driven by one shared progress component. Add
+ `code_addons` and `code_assets` (and a forgejo job type) to the job types.
+
+One progress contract across all four: `{ phase, percent (-1 = indeterminate),
+current, total, speed, detail }`. Each updater fills what it can (download bytes
+for add-ons/assets; per-repo for Forgejo; compile phases + ETA for the rebuild).
+Not identical metrics in all four: one shape, one UI, filled per source.
+
+## Lifecycle (who sets / clears / what if it dies)
+
+- Pause / resume / cancel state is owned by the durable job engine (existing, it
+ survives a dash-node restart). No new persistent markers are added.
+- Resume is aria2 `--continue` plus the `.aria2` control file; pause keeps the
+ partial, cancel prunes it (existing `cleanupPartials`). A process death
+ mid-download leaves a resumable partial, not a half-applied update: the atomic
+ staging swap still only runs after a clean, verified download.
+- The content-specific finalize (page + swap) stays outside the download, so a
+ paused or failed download never serves a partial tree.
+
+## Reuse, not duplication
+
+- Do NOT add a fourth bespoke downloader. The content runners reuse the aria2
+ args + `withRetry` loop from `kiwix.exec.ts` / `maps-base.exec.ts`.
+- The per-module detached-job client and the dash-node route trio are the
+ content-module shared-extraction follow-up: the app side rides one shared
+ `RestContentClient` / `DetachedJobClient`, not three near-copies. This ADR
+ depends on that extraction (or lands with it) so the three content updaters
+ share one path.
+
+## Scope and phasing (pragmatic)
+
+- K2GO-443 (content): add-ons and build assets first (large files, where aria2
+ resilience matters most), then Forgejo (git, per-repo). Build assets is the
+ natural first slice (9 large `.br` files).
+- K2GO-383 (dashboard rebuild): percent + ETA + persist on the compile; separate
+ ticket, shares the progress contract and the UI component.
+- Both are APK + dash-node: dash-node emits the structured progress; the app
+ renders it with the shared component.
+
+## Out of scope / notes
+
+- git mid-transfer pause/resume (not an aria2 download): retry-per-repo only.
+- Origin flakiness is a possibility, not an established fact (an R2 move has been
+ floated but not confirmed; it is being looked at separately). aria2's
+ `--continue` + `--max-tries` + the outer loop is general resilience that would
+ help any flaky origin; this ADR adds no origin-specific code or assumption.
+- The canonical aria2 arg set is already shared text across runners +
+ `Aria2Manager`; adding content runners keeps that note's "change all copies"
+ rule (or extract the args once as part of the shared work).
diff --git a/static/dashboard/CHANGELOG.md b/static/dashboard/CHANGELOG.md
index 635d3c10a..ec9a929b3 100644
--- a/static/dashboard/CHANGELOG.md
+++ b/static/dashboard/CHANGELOG.md
@@ -4,6 +4,7 @@ One line per version, newest first. Every REST-facing change bumps the version i
(the app surfaces it via `/system/dashboard/update-check` and the "Update available" pill), so this
file is the human record of what each bump enables. Keep entries short: `version - change (TICKET)`.
+- **1.3.11** - Build-assets download on the durable job engine (K2GO-443). `code-assets` is now a job type: `POST /code-assets/download` plus `GET /code-assets/jobs/:id` (structured `{phase, percent, speed, detail}`) and pause/resume/retry/cancel over the generic `/:type/*` surface, like kiwix/maps. The runner (`sockets/code_assets.exec.ts`) downloads the build assets with aria2c (resilient: `--continue` resume, survives a full interface loss via the outer retry loop) using the shared `downloadWithAria2` helper, then the mirror verifies each file against its published `.md5` and writes the browse page (`mirror_code_assets.py --finalize-only`), and the runner swaps the staged tree in atomically. The older `POST /code-assets/refresh` (wrapper) stays for now. Localhost-only. (K2GO-443)
- **1.3.10** - Code on the Go build-assets refresh (K2GO-437). New `POST /code-assets/refresh` re-mirrors the Code on the Go build assets into `/library/www/code-assets` LIVE (box up, no runrole) through a detached wrapper (`tools/code-assets-refresh.sh`) that mirrors into a staging dir and swaps it in only on success (a failed or cancelled refresh never serves a half-mirror), `GET /code-assets/refresh/status` returns `{state, lines, downloaded, reused, failed, upToDate}` to poll (counts parsed from the mirror's final log line; `upToDate` when nothing changed), and `POST /code-assets/refresh/cancel` stops a running refresh (SIGKILL to the wrapper process group; safe because the live tree is only replaced after a clean run). The wrapper reuses the role's `mirror_code_assets.py` (manifest-driven, per-file `.md5` verify and reuse, generates the browse page), so install (bake) and refresh (live) share one mechanism. Localhost-only. (K2GO-437)
- **1.3.9** - Incremental add-ons refresh with an "up to date" report (K2GO-441). "Update add-ons" no longer re-downloads the whole gallery. `mirror_addons.py` takes `--reuse-from` the live tree and prints `result: up-to-date` when the published catalog is unchanged (nothing downloaded, no swap); it reads what changed straight from `catalog.json` (the single source of truth): it reuses unchanged `.cgp` and source tarballs by the catalog sha256, reuses each add-on's icon and page (both live inside the source tarball) when that sha is unchanged, and reuses shell assets by their content-hashed name, so only new or changed files download, with no local re-hash and no extra requests. `GET /addons/refresh/status` now also returns `{reused, upToDate}`. The app shows "already up to date" when nothing came down. (K2GO-441)
- **1.3.8** - Add-ons offline gallery refresh (K2GO-99). New `POST /addons/refresh` re-mirrors the published Code on the Go add-ons gallery into `/library/www/code-addons` LIVE (box up, no runrole) through a detached wrapper (`tools/code-addons-refresh.sh`) that mirrors into a staging dir and swaps it in only on success (a failed or cancelled refresh never serves a half-mirror), `GET /addons/refresh/status` returns `{state, lines, downloaded, failed}` to poll (downloaded/failed parsed from the mirror's final log line), and `POST /addons/refresh/cancel` stops a running refresh (SIGKILL to the wrapper process group; safe because the live gallery is only replaced after a clean run). The wrapper reuses the role's `mirror_addons.py` (reference-driven mirror, sha256 verify, Cloudflare strip, catalog base rewrite), so install (bake) and refresh (live) share one mechanism. Localhost-only. (K2GO-99)
diff --git a/static/dashboard/package.json b/static/dashboard/package.json
index abc2c8e6f..f325b99ab 100644
--- a/static/dashboard/package.json
+++ b/static/dashboard/package.json
@@ -1,6 +1,6 @@
{
"name": "dashboard-console",
- "version": "1.3.10",
+ "version": "1.3.11",
"description": "",
"main": "index.js",
"scripts": {
diff --git a/static/dashboard/routes.ts b/static/dashboard/routes.ts
index b0aeaa248..354404e56 100644
--- a/static/dashboard/routes.ts
+++ b/static/dashboard/routes.ts
@@ -40,7 +40,7 @@ const ZIMS_DIR = '/library/zims/content/';
const KIWIX_INDEXER = '/usr/bin/iiab-make-kiwix-lib';
const ZIM_NAME_RE = /^[A-Za-z0-9._-]{1,150}\.zim$/;
-const VALID_TYPES: JobType[] = ['kiwix', 'maps', 'books', 'kolibri', 'basemaps'];
+const VALID_TYPES: JobType[] = ['kiwix', 'maps', 'books', 'kolibri', 'basemaps', 'code-assets'];
function isType(t: string): t is JobType {
return (VALID_TYPES as string[]).includes(t);
}
@@ -1117,6 +1117,15 @@ apiRouter.post('/:type/download', (req: Request, res: Response): void => {
? body.items
: Array.isArray(body?.ids) ? body.ids : [];
if (items.length === 0) { res.status(400).json({ error: 'items (or ids) required' }); return; }
+ // K2GO-443: code-assets stages into one shared tree (/library/www/code-assets.new), unlike kiwix's
+ // independent files, so only one build-assets job may run at a time. The app re-attaches via
+ // start-or-attach; this is the hard guard behind it (two concurrent jobs would corrupt the staging).
+ if (type === 'code-assets'
+ && jobs.list('code-assets').some((j) =>
+ ['queued', 'downloading', 'indexing', 'processing', 'paused'].includes(j.phase))) {
+ res.status(409).json({ error: 'a build-assets job is already running' });
+ return;
+ }
res.status(202).json(toApi(jobs.create(type, items)));
});
diff --git a/static/dashboard/server.ts b/static/dashboard/server.ts
index b62995447..0f9d3ee51 100644
--- a/static/dashboard/server.ts
+++ b/static/dashboard/server.ts
@@ -10,6 +10,7 @@ import './sockets/maps.exec';
import './sockets/maps-base.exec';
import './sockets/books.exec';
import './sockets/kolibri.exec';
+import './sockets/code_assets.exec'; // K2GO-443: build-assets runner (aria2 job engine)
import { apiRouter } from './routes';
import { startServiceHeal } from './sockets/service-heal';
import { startLogRotation, stopLogRotation } from './sockets/log-rotate';
diff --git a/static/dashboard/sockets/aria2-download.ts b/static/dashboard/sockets/aria2-download.ts
new file mode 100644
index 000000000..6981d742c
--- /dev/null
+++ b/static/dashboard/sockets/aria2-download.ts
@@ -0,0 +1,124 @@
+// sockets/aria2-download.ts - K2GO-443
+//
+// Shared aria2 download primitive for durable-job runners. Extracts the proven mechanism that
+// kiwix.exec.ts and maps-base.exec.ts each copied (ADFA-4832 canonical flag set + the withRetry
+// OUTER loop that survives a full interface loss): download a set of URLs into a dest dir with
+// aria2c, report percent + bytes/sec on the job, and handle pause / cancel / reconnect the same way
+// everywhere. New runners (code_assets, and later add-ons) call downloadWithAria2 instead of copying
+// the flags a fourth time. kiwix.exec.ts / maps-base.exec.ts keep their copies until migrated here.
+//
+// Canonical aria2 flags are ALSO mirrored in controller/app/.../Aria2Manager.java (the Android
+// downloader). If you change a flag here, change that too (nothing enforces it).
+import { RunnerContext, CanceledError, PausedError, classifyStop, JobPhase } from './jobs';
+import { withRetry } from './net-retry';
+
+/** The canonical aria2 flag set (minus -d, which the caller supplies per dest). */
+export function aria2Args(destDir: string): string[] {
+ return [
+ '-d', destDir,
+ '--continue=true',
+ '--allow-overwrite=true',
+ '--auto-file-renaming=false',
+ '--max-connection-per-server=4',
+ '--split=16',
+ '--follow-metalink=mem',
+ '--check-integrity=true',
+ '--console-log-level=warn',
+ '--summary-interval=1',
+ '--download-result=hide',
+ '--async-dns=false',
+ // aria2 absorbs in-flight blips itself (a 0-wait retry hammers a struggling server). A FULL
+ // interface loss (exit 19, DNS cannot resolve) is handled by the outer withRetry loop below.
+ '--max-tries=5',
+ '--retry-wait=5',
+ '--timeout=60',
+ '--connect-timeout=15',
+ // No --lowest-speed-limit, on purpose: it turns a slow mobile link into a hard abort, the
+ // opposite of resilient on the intermittent links this exists for.
+ '-Z',
+ '-j', '5',
+ ];
+}
+
+// Transient aria2 exit codes the outer loop re-runs (aria2 resumes via --continue): 1 unknown,
+// 2 timeout, 6 network, 7 unfinished, 19 DNS, 29 HTTP 503. Terminal (not retried): 3/4 not found,
+// 9 no space, 13 file exists.
+export const ARIA2_TRANSIENT_EXITS = new Set([1, 2, 6, 7, 19, 29]);
+
+// 5 visible reconnect waits (3, 6, 9, 18, 36 s ~ 72 s total), surfaced on the poll as "Reconnecting
+// n/5"; the app renders it and can cancel a wait (which pauses via ctx.signal).
+const RETRY_DELAYS = [3_000, 6_000, 9_000, 18_000, 36_000];
+
+/** Convert an aria2 rate token ("34MiB", "512KiB", "1.2MB") to bytes/sec. */
+export function parseRate(token: string): number {
+ const m = /^([\d.]+)\s*([KMGT]?i?B)?/i.exec(token);
+ if (!m) return 0;
+ const val = parseFloat(m[1]);
+ const unit = (m[2] || 'B').toUpperCase();
+ const mult: Record = {
+ B: 1, KIB: 1024, MIB: 1024 ** 2, GIB: 1024 ** 3, TIB: 1024 ** 4,
+ KB: 1000, MB: 1e6, GB: 1e9, TB: 1e12,
+ };
+ return Math.round(val * (mult[unit] ?? 1));
+}
+
+/**
+ * Download urls into destDir with aria2c, reporting percent + speed on the job and surviving a
+ * Wi-Fi drop (the outer loop re-runs aria2, which resumes via --continue). Resolves on success.
+ * Throws PausedError (partial kept) / CanceledError (caller cleans) / Error (real failure).
+ *
+ * The caller owns the phase label and any verify/finalize: this does the resilient transfer only.
+ */
+export async function downloadWithAria2(
+ ctx: RunnerContext,
+ opts: { destDir: string; urls?: string[]; inputFile?: string; phase?: JobPhase },
+): Promise {
+ const phase = opts.phase ?? 'downloading';
+ // -i inputFile carries a per-URL `out=` so a serve-relative subdir is preserved (code_assets);
+ // urls is the flat form (kiwix/maps, one dir). The out= paths are relative to -d destDir.
+ const transferArgs = opts.inputFile ? ['-i', opts.inputFile] : (opts.urls ?? []);
+ await withRetry(() => new Promise((resolve, reject) => {
+ const dl = ctx.spawn('/usr/bin/aria2c', [...aria2Args(opts.destDir), ...transferArgs]);
+ const onData = (buf: Buffer) => {
+ const text = buf.toString();
+ // A single chunk can carry several summary lines; take the LAST %/rate.
+ const re = /\((\d+)%\).*?DL:([^\s]+)/g;
+ let m: RegExpExecArray | null;
+ let lastPct = -1;
+ let lastRate = '';
+ while ((m = re.exec(text)) !== null) { lastPct = parseInt(m[1], 10); lastRate = m[2]; }
+ if (lastPct >= 0) {
+ ctx.reportRetry(0, 0);
+ ctx.update({ phase, percent: lastPct, speed: parseRate(lastRate) });
+ }
+ };
+ dl.stdout?.on('data', onData);
+ dl.stderr?.on('data', onData);
+ dl.on('error', reject);
+ dl.on('exit', (code, signal) => {
+ if (signal === 'SIGKILL' || ctx.isCanceled()) return reject(new CanceledError());
+ if (code === 0) return resolve();
+ const err = new Error(`aria2 exited with code ${code}`);
+ (err as { code?: number }).code = code ?? -1;
+ reject(err);
+ });
+ }), {
+ delaysMs: RETRY_DELAYS,
+ tries: RETRY_DELAYS.length + 1,
+ signal: ctx.signal,
+ isCanceled: ctx.isCanceled,
+ isTransient: (e) => ARIA2_TRANSIENT_EXITS.has((e as { code?: number }).code ?? -1),
+ onRetry: ({ attempt, err }: { attempt: number; err: unknown }) => {
+ ctx.reportRetry(attempt, RETRY_DELAYS.length);
+ ctx.log(`[aria2] reconnect ${attempt}/${RETRY_DELAYS.length} after: ${err instanceof Error ? err.message : String(err)}`);
+ },
+ });
+}
+
+/** Map a stop (pause vs cancel) to the error a runner should throw; returns null for a real error. */
+export function stopError(ctx: RunnerContext): PausedError | CanceledError | null {
+ const stop = classifyStop(ctx);
+ if (stop === 'paused') return new PausedError();
+ if (stop === 'canceled') return new CanceledError();
+ return null;
+}
diff --git a/static/dashboard/sockets/code_assets.exec.ts b/static/dashboard/sockets/code_assets.exec.ts
new file mode 100644
index 000000000..0ff37051d
--- /dev/null
+++ b/static/dashboard/sockets/code_assets.exec.ts
@@ -0,0 +1,131 @@
+// sockets/code_assets.exec.ts - K2GO-443
+//
+// Code on the Go build-assets runner for the durable job engine. Replaces the minimalist
+// wrapper (tools/code-assets-refresh.sh) with an aria2 download that gives percent / speed /
+// pause / resume / retry and survives a network change, like kiwix / maps. The Python mirror
+// keeps the content-specific work: --print-aria2-input stages the UNCHANGED files from the live
+// tree and prints an aria2 input-file (url + out + checksum=md5) for only the files that changed
+// (so a routine update re-downloads nothing when nothing changed); --finalize-only builds the
+// browse page (aria2 already verified each file by its checksum). The runner swaps the staged
+// tree in atomically, restoring the previous tree if the swap fails.
+//
+// Option A (ADR-updater-progress-resilience): aria2 download in the runner, plan + verify + page
+// in the mirror, swap in the runner. The mirror is read from the self-updating clone when present
+// (K2GO-440), else the bake-time copy.
+import { jobs, RunnerContext, CanceledError, PausedError } from './jobs';
+import { downloadWithAria2, stopError } from './aria2-download';
+import { execFileSync } from 'child_process';
+import fs from 'fs';
+
+const SERVE = '/library/www/code-assets';
+const STAGE = `${SERVE}.new`;
+const OLD = `${SERVE}.old`;
+// K2GO-440: prefer the mirror from the self-updating clone so a mirror fix ships via the dash-node
+// rebuild with no rebake; fall back to the bake-time copy in the ansible roles dir.
+const MIRROR_CLONE = '/opt/iiab-android/tools/upstream-patches/overlays/roles/code_assets/files/mirror_code_assets.py';
+const MIRROR_BAKED = '/opt/iiab/iiab/roles/code_assets/files/mirror_code_assets.py';
+
+function mirrorPath(): string {
+ return fs.existsSync(MIRROR_CLONE) ? MIRROR_CLONE : MIRROR_BAKED;
+}
+
+function rmrf(p: string): void {
+ try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ }
+}
+
+/** Remove aria2 control/metadata so the served tree is only the assets + .md5 + index.html. */
+function cleanAria2Files(dir: string): void {
+ try { execFileSync('find', [dir, '-name', '*.aria2', '-delete']); } catch { /* best effort */ }
+}
+
+/** Run the mirror and resolve with its stdout; rejects (paused/canceled/error) like a download step. */
+function runMirror(ctx: RunnerContext, mirror: string, args: string[], capture: boolean): Promise {
+ return new Promise((resolve, reject) => {
+ let out = '';
+ const p = ctx.spawn('python3', [mirror, ...args]);
+ p.stdout?.on('data', (d: Buffer) => { if (capture) out += d.toString(); else ctx.log(d.toString().trim()); });
+ p.stderr?.on('data', (d: Buffer) => ctx.log(d.toString().trim()));
+ p.on('error', reject);
+ p.on('exit', (code, signal) => {
+ if (signal === 'SIGKILL' || ctx.isCanceled()) return reject(new CanceledError());
+ if (code === 0) return resolve(out);
+ reject(new Error(`${args[0]} failed (exit ${code})`));
+ });
+ });
+}
+
+const codeAssetsRunner: (ctx: RunnerContext) => Promise = async (ctx) => {
+ const MIRROR = mirrorPath();
+ if (!fs.existsSync(MIRROR)) throw new Error(`mirror script not found: ${MIRROR}`);
+
+ // --- Plan (incremental) -------------------------------------------------
+ // The mirror stages the UNCHANGED files into STAGE from the live tree and prints an aria2
+ // input-file for only the changed files (empty = nothing changed). STAGE is NOT pre-cleared, so
+ // a resume keeps its partials; the plan is deterministic and safe to re-run.
+ ctx.update({ phase: 'downloading', percent: -1, speed: 0, detail: 'build assets' });
+ let input: string;
+ try {
+ input = await runMirror(ctx, MIRROR, ['--print-aria2-input', '--reuse-from', SERVE, '--out', STAGE], true);
+ } catch (e) {
+ const se = stopError(ctx);
+ if (se instanceof PausedError) throw se;
+ if (se instanceof CanceledError) { rmrf(STAGE); throw se; }
+ throw e;
+ }
+ ctx.throwIfCanceled();
+
+ if (input.trim() === '') {
+ // Every file unchanged: keep the live tree, nothing to download or swap.
+ rmrf(STAGE);
+ ctx.update({ phase: 'done', percent: 100, speed: 0, detail: 'up to date' });
+ return;
+ }
+
+ // --- Download the changed files (aria2, resilient) ---------------------
+ const inputFile = `${STAGE}/.aria2-input`;
+ fs.mkdirSync(STAGE, { recursive: true });
+ fs.writeFileSync(inputFile, input);
+ try {
+ await downloadWithAria2(ctx, { destDir: STAGE, inputFile, phase: 'downloading' });
+ } catch (e) {
+ const se = stopError(ctx);
+ if (se instanceof PausedError) throw se; // keep STAGE: resume continues
+ if (se instanceof CanceledError) { rmrf(STAGE); throw se; } // cancel discards the partial
+ throw e; // real error: keep STAGE for a retry/resume
+ }
+ ctx.throwIfCanceled();
+
+ // --- Build the page (aria2 verified each file by checksum; no re-hash) --
+ ctx.update({ phase: 'processing', percent: -1, speed: 0, detail: 'finishing' });
+ try {
+ await runMirror(ctx, MIRROR, ['--finalize-only', '--out', STAGE], false);
+ } catch (e) {
+ const se = stopError(ctx);
+ if (se) throw se;
+ throw e;
+ }
+ ctx.throwIfCanceled();
+
+ // --- Swap (atomic rename; restore the previous tree if the move fails) --
+ ctx.update({ phase: 'processing', percent: 100, speed: 0, detail: 'installing' });
+ try { fs.rmSync(inputFile, { force: true }); } catch { /* leave nothing non-served behind */ }
+ cleanAria2Files(STAGE);
+ rmrf(OLD);
+ if (fs.existsSync(SERVE)) fs.renameSync(SERVE, OLD);
+ try {
+ fs.renameSync(STAGE, SERVE);
+ } catch (e) {
+ // A failed swap must never leave the box with no served assets: put the previous tree back.
+ if (!fs.existsSync(SERVE) && fs.existsSync(OLD)) {
+ try { fs.renameSync(OLD, SERVE); } catch { /* best effort */ }
+ }
+ throw e;
+ }
+ rmrf(OLD);
+
+ ctx.update({ phase: 'done', percent: 100, speed: 0 });
+};
+
+jobs.registerRunner('code-assets', codeAssetsRunner);
+
+export { codeAssetsRunner };
diff --git a/static/dashboard/sockets/jobs.ts b/static/dashboard/sockets/jobs.ts
index 06ad91ed3..fad004693 100644
--- a/static/dashboard/sockets/jobs.ts
+++ b/static/dashboard/sockets/jobs.ts
@@ -13,7 +13,7 @@ import fs from 'fs';
import path from 'path';
import { RollingLog, LogSlice } from './rolling-log';
-export type JobType = 'kiwix' | 'maps' | 'books' | 'kolibri' | 'basemaps';
+export type JobType = 'kiwix' | 'maps' | 'books' | 'kolibri' | 'basemaps' | 'code-assets';
export type JobPhase =
| 'queued' | 'downloading' | 'indexing' | 'processing'
// ADFA-4894 (control surface): 'paused' is a stopped-but-resumable state — like 'canceled' it
diff --git a/tools/upstream-patches/overlays/roles/code_assets/files/mirror_code_assets.py b/tools/upstream-patches/overlays/roles/code_assets/files/mirror_code_assets.py
index 99e668d2d..5c9c4fc22 100644
--- a/tools/upstream-patches/overlays/roles/code_assets/files/mirror_code_assets.py
+++ b/tools/upstream-patches/overlays/roles/code_assets/files/mirror_code_assets.py
@@ -350,6 +350,75 @@ def mirror(source_base, serve_base, out, manifest_path=MANIFEST_DEFAULT,
return failed == 0
+def aria2_input(source_base, out, manifest_path=MANIFEST_DEFAULT, reuse_from=None):
+ """Emit an aria2 input-file for the files that must be DOWNLOADED (K2GO-443): per asset a URL,
+ an `out=` (keeps the serve-relative subdir), and `checksum=md5=` so aria2 verifies
+ integrity during the transfer. Files whose published .md5 still matches the served one are UNCHANGED:
+ they are staged straight into `out` from the served tree (no download), exactly like the mirror's
+ --reuse-from. Each asset's .md5 sidecar (the published md5) is written into `out`, so finalize()
+ only builds the page. Prints NOTHING when every file is unchanged: the runner treats empty input as
+ 'up to date' and keeps the live tree. Reuse is compared by the published vs served .md5, no re-hash."""
+ source_base = source_base.rstrip("/")
+ out = Path(out)
+ reuse_from = Path(reuse_from) if reuse_from else None
+ assets = load_manifest(manifest_path)
+ published = {a["path"]: published_md5(source_base, a["path"]) for a in assets}
+ reuse_ok = {}
+ for a in assets:
+ p = a["path"]
+ reuse_ok[p] = (
+ reuse_from is not None
+ and published[p] is not None
+ and served_md5(reuse_from, p) == published[p]
+ and (reuse_from / p).is_file()
+ )
+ if all(reuse_ok[a["path"]] for a in assets):
+ return True # every file unchanged: print nothing, stage nothing (up to date)
+
+ lines = []
+ for a in assets:
+ p = a["path"]
+ dest = out / p
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ want = published[p]
+ if want is not None:
+ # Sidecar from the published md5; aria2 verifies the downloaded file matches it (checksum= below).
+ dest.with_name(dest.name + ".md5").write_text(f"{want} {Path(p).name}\n", encoding="utf-8")
+ if reuse_ok[p]:
+ shutil.copyfile(reuse_from / p, dest) # unchanged: stage from the served tree, no download
+ else:
+ # aria2 does not reliably place a file from an out= that contains a subdir (on device it
+ # wrote to the -d root, which would also collide the v7/v8 same-named files). Use an absolute
+ # per-entry dir= plus a basename out=, so each file lands in its serve-relative subdir.
+ lines.append(f"{source_base}/{p}")
+ lines.append(f" dir={out}/{Path(p).parent}")
+ lines.append(f" out={Path(p).name}")
+ if want is not None:
+ lines.append(f" checksum=md5={want}")
+ sys.stdout.write("\n".join(lines) + "\n")
+ return True
+
+
+def finalize(serve_base, out, manifest_path=MANIFEST_DEFAULT, verbose=True):
+ """Build the browse page after the aria2 download (K2GO-443). aria2 verified each downloaded file
+ against its checksum, and the .md5 sidecars were written by --print-aria2-input, so this only
+ confirms each file is present and renders the page: no re-download, no re-hash."""
+ serve_base = "/" + serve_base.strip("/")
+ out = Path(out)
+ assets = load_manifest(manifest_path)
+ failed = 0
+ for a in assets:
+ if not (out / a["path"]).is_file():
+ failed += 1
+ print(f" MISSING {a['path']}: not present after download", file=sys.stderr)
+ sizes = _staged_sizes_from(out, assets)
+ (out / INDEX).write_bytes(build_index(assets, sizes, serve_base))
+ if verbose:
+ print(f"wrote {INDEX} ({len(assets)} assets)")
+ print(f"done: {len(assets) - failed} present, {failed} missing")
+ return failed == 0
+
+
def main(argv=None):
ap = argparse.ArgumentParser(description="Mirror the Code on the Go build assets.")
ap.add_argument("--source-base", default="https://appdevforall.org/dev-assets",
@@ -358,15 +427,34 @@ def main(argv=None):
help="local serve path, used for the copy-link URLs on the page")
ap.add_argument("--manifest", default=str(MANIFEST_DEFAULT),
help="asset manifest (paths, titles, descriptions)")
- ap.add_argument("--out", required=True, help="output mirror directory")
+ ap.add_argument("--out", default=None, help="output mirror directory")
ap.add_argument("--reuse-from", default=None,
help="the currently served tree; reuse unchanged files from it "
"instead of re-downloading (compared by the published .md5)")
ap.add_argument("--plan-only", action="store_true",
help="test only: fetch the .md5 set and report the plan, "
"download no large file and write no page")
+ # K2GO-443: the durable job engine downloads the assets with aria2 (resilient: percent, pause,
+ # resume, retry), then calls --finalize-only to verify and build the page. --print-aria2-input
+ # hands the runner the url+out list from this one source (manifest + base).
+ ap.add_argument("--print-aria2-input", action="store_true",
+ help="print an aria2 input-file (url + out=path per asset) for the job runner")
+ ap.add_argument("--finalize-only", action="store_true",
+ help="verify already-downloaded files (by the published .md5) and write the page, "
+ "without downloading (used after the aria2 job-engine download)")
ap.add_argument("--quiet", action="store_true")
args = ap.parse_args(argv)
+
+ if args.print_aria2_input:
+ if not args.out:
+ ap.error("--out is required for --print-aria2-input")
+ return 0 if aria2_input(args.source_base, args.out,
+ manifest_path=args.manifest, reuse_from=args.reuse_from) else 1
+ if not args.out:
+ ap.error("--out is required")
+ if args.finalize_only:
+ ok = finalize(args.serve_base, args.out, manifest_path=args.manifest, verbose=not args.quiet)
+ return 0 if ok else 1
ok = mirror(args.source_base, args.serve_base, args.out,
manifest_path=args.manifest, reuse_from=args.reuse_from,
plan_only=args.plan_only, verbose=not args.quiet)