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)