Skip to content

Commit 087362a

Browse files
Merge pull request #619 from appdevforall/feat/K2GO-437-code-assets
K2GO-437 feat(code-assets): serve Code on the Go build assets offline at /code-assets
2 parents bed2832 + 8865d4f commit 087362a

55 files changed

Lines changed: 1597 additions & 2 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎controller/app/src/main/java/org/appdevforall/k2go/ModuleRegistry.java‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ public IiabModule(String endpoint, int nameResId, boolean requires64Bit, String
3939
new IiabModule("maps", R.string.dash_maps, false, "maps"), // YAML uses maps_install
4040
new IiabModule("matomo", R.string.dash_matomo, false, "matomo"), // YAML uses matomo_install
4141
new IiabModule("forgejo", R.string.dash_forgejo, false, "forgejo"), // YAML uses forgejo_install
42-
new IiabModule("code-addons", R.string.dash_code_addons, false, "code_addons") // K2GO-99: offline Code on the Go add-ons gallery; YAML uses code_addons_install, served at /code-addons
42+
new IiabModule("code-addons", R.string.dash_code_addons, false, "code_addons"), // K2GO-99: offline Code on the Go add-ons gallery; YAML uses code_addons_install, served at /code-addons
43+
new IiabModule("code-assets", R.string.dash_code_assets, false, "code_assets") // K2GO-437: offline Code on the Go build assets; YAML uses code_assets_install, served at /code-assets
4344
// ADFA-4842: "dashboard" is intentionally NOT in the roster. It is no longer an
4445
// application/module — it became the REST API core of K2Go; without it maps FQR and all
4546
// REST content downloads fail. It is part of the core system, not a user-installable module.
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
/*
2+
* ============================================================================
3+
* Name : CodeAssetsRefreshClient.java
4+
* Author : AppDevForAll
5+
* Copyright : Copyright (c) 2026 AppDevForAll
6+
* Description : K2GO-437. App-side client of the dash-node build-assets refresh
7+
* (static/dashboard routes.ts). The box re-mirrors the Code on the
8+
* Go build assets while the server is UP: the device only POSTs to
9+
* start and polls a coarse status, so a dropped app never stops the
10+
* refresh (the box job is detached). Same POST-then-poll shape as
11+
* AddonsRefreshClient; the status carries downloaded, reused, and
12+
* failed counts plus up-to-date.
13+
*
14+
* Contract:
15+
* POST /k2go-api/code-assets/refresh -> 202 { state:"running" }
16+
* GET /k2go-api/code-assets/refresh/status -> { state, lines, downloaded, reused, failed, upToDate }
17+
* POST /k2go-api/code-assets/refresh/cancel -> { state:"cancelled" }
18+
* ============================================================================
19+
*/
20+
package org.appdevforall.k2go.codeassets.data;
21+
22+
import androidx.annotation.NonNull;
23+
import androidx.annotation.Nullable;
24+
25+
import org.appdevforall.k2go.config.BoxEndpoints;
26+
import org.json.JSONArray;
27+
import org.json.JSONObject;
28+
29+
import java.io.ByteArrayOutputStream;
30+
import java.io.InputStream;
31+
import java.net.HttpURLConnection;
32+
import java.net.URL;
33+
import java.nio.charset.StandardCharsets;
34+
35+
/**
36+
* Drives the box build-assets refresh to a terminal state. {@link #refresh} BLOCKS (poll loop), so
37+
* callers run it on an IO thread. The box job is detached, so a caller that dies mid-run does not stop
38+
* it: a later {@link #refresh} re-attaches by reading the same status (a running refresh is left alone).
39+
*/
40+
public final class CodeAssetsRefreshClient {
41+
42+
/** Terminal verdict of a refresh. */
43+
public enum Result { DONE, ERROR, CANCELLED }
44+
45+
/** Streamed status-tail lines, for a live one-line view. Optional (pass null to ignore). */
46+
public interface Listener {
47+
void onLine(@NonNull String line);
48+
}
49+
50+
private static final String REFRESH_URL = BoxEndpoints.API + "/code-assets/refresh";
51+
private static final String REFRESH_STATUS_URL = BoxEndpoints.API + "/code-assets/refresh/status";
52+
private static final String REFRESH_CANCEL_URL = BoxEndpoints.API + "/code-assets/refresh/cancel";
53+
private static final long POLL_MS = 2000L;
54+
private static final int MAX_POLL_ERRORS = 15; // ~30s of transient blips before giving up
55+
// The mirror pulls the release build set (hundreds of MB), so the run can take many minutes; cap
56+
// the wait so a wedged box job cannot block forever (the box job keeps running detached).
57+
private static final long MAX_WAIT_MS = 30 * 60 * 1000L;
58+
59+
/** The last status-tail line handed to the listener, so a poll that did not advance stays quiet. */
60+
private String lastEmitted;
61+
private int lastDownloaded = -1, lastReused = -1, lastFailed = -1;
62+
private boolean lastUpToDate = false;
63+
64+
/** Files downloaded in the last refresh; -1 if the box did not report it. */
65+
public int lastDownloaded() { return lastDownloaded; }
66+
/** Files reused unchanged in the last refresh; -1 if the box did not report it. */
67+
public int lastReused() { return lastReused; }
68+
/** Files the last refresh could not fetch or verify; -1 if the box did not report it. */
69+
public int lastFailed() { return lastFailed; }
70+
/** True when the published set was unchanged, so the refresh downloaded nothing. */
71+
public boolean lastUpToDate() { return lastUpToDate; }
72+
73+
/**
74+
* Start the refresh if it is not already running, then poll to a terminal state. Each refresh is
75+
* intentional, so there is no "done" short-circuit: unless one is already running (re-attach), it
76+
* POSTs a fresh run. The box refresh is safe to re-run (it mirrors into a staging dir and only
77+
* swaps the live tree on success). Returns DONE when the box refresh finished, ERROR on an
78+
* unreachable box or timeout, CANCELLED if the user stopped it.
79+
*/
80+
@NonNull
81+
public Result refresh(@Nullable Listener l) {
82+
String state = readState(l);
83+
if (!"running".equals(state)) {
84+
if (!post(REFRESH_URL)) return Result.ERROR;
85+
}
86+
return poll(l);
87+
}
88+
89+
/** Ask the box to stop a running refresh (best-effort; a poll then reads "cancelled"). */
90+
public void cancel() {
91+
post(REFRESH_CANCEL_URL);
92+
}
93+
94+
@NonNull
95+
private Result poll(@Nullable Listener l) {
96+
final long deadline = System.currentTimeMillis() + MAX_WAIT_MS;
97+
int pollErrors = 0;
98+
while (System.currentTimeMillis() < deadline) {
99+
try { Thread.sleep(POLL_MS); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); return Result.ERROR; }
100+
String s = readState(l);
101+
if (s == null) { if (++pollErrors > MAX_POLL_ERRORS) return Result.ERROR; continue; }
102+
pollErrors = 0;
103+
if ("done".equals(s)) return Result.DONE;
104+
if ("error".equals(s)) return Result.ERROR;
105+
if ("cancelled".equals(s)) return Result.CANCELLED;
106+
// "running" (or an unknown transient) -> keep polling.
107+
}
108+
return Result.ERROR; // timed out; the box job may still finish, a later refresh re-checks
109+
}
110+
111+
/** POST a start/cancel endpoint; true if accepted (2xx) or already running (409). */
112+
private boolean post(@NonNull String url) {
113+
try {
114+
HttpURLConnection c = open("POST", url);
115+
int code = c.getResponseCode();
116+
c.disconnect();
117+
return (code >= 200 && code < 300) || code == 409;
118+
} catch (Exception e) {
119+
return false;
120+
}
121+
}
122+
123+
/** GET the status endpoint -> the state string, streaming any new tail line; null on a read error. */
124+
@Nullable
125+
private String readState(@Nullable Listener l) {
126+
try {
127+
HttpURLConnection c = open("GET", REFRESH_STATUS_URL);
128+
int code = c.getResponseCode();
129+
boolean ok = code >= 200 && code < 400;
130+
String text = readAll(ok ? c.getInputStream() : c.getErrorStream());
131+
c.disconnect();
132+
if (!ok) return null;
133+
JSONObject j = new JSONObject(text.isEmpty() ? "{}" : text);
134+
if (j.has("downloaded")) lastDownloaded = j.optInt("downloaded", lastDownloaded);
135+
if (j.has("reused")) lastReused = j.optInt("reused", lastReused);
136+
if (j.has("failed")) lastFailed = j.optInt("failed", lastFailed);
137+
if (j.has("upToDate")) lastUpToDate = j.optBoolean("upToDate", lastUpToDate);
138+
if (l != null) {
139+
JSONArray lines = j.optJSONArray("lines");
140+
if (lines != null && lines.length() > 0) {
141+
String last = lines.optString(lines.length() - 1, "");
142+
if (!last.isEmpty() && !last.equals(lastEmitted)) { lastEmitted = last; l.onLine(last); }
143+
}
144+
}
145+
return j.optString("state", "");
146+
} catch (Exception e) {
147+
return null;
148+
}
149+
}
150+
151+
private static HttpURLConnection open(String method, String urlStr) throws Exception {
152+
HttpURLConnection c = (HttpURLConnection) new URL(urlStr).openConnection();
153+
c.setUseCaches(false);
154+
c.setConnectTimeout(4000);
155+
c.setReadTimeout(4000);
156+
c.setRequestMethod(method);
157+
c.setRequestProperty("Accept", "application/json");
158+
return c;
159+
}
160+
161+
private static String readAll(InputStream is) throws Exception {
162+
if (is == null) return "";
163+
ByteArrayOutputStream buf = new ByteArrayOutputStream();
164+
byte[] chunk = new byte[4096];
165+
int n;
166+
while ((n = is.read(chunk)) != -1) buf.write(chunk, 0, n);
167+
is.close();
168+
return buf.toString(StandardCharsets.UTF_8.name());
169+
}
170+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/*
2+
* ============================================================================
3+
* Name : CodeAssetsRefresh.java
4+
* Author : AppDevForAll
5+
* Copyright : Copyright (c) 2026 AppDevForAll
6+
* Description : K2GO-437. The single "Update build assets" flow, shared by the module detail button and
7+
* the module action sheet row so neither duplicates it. Modeled on AddonsRefresh: it gates
8+
* like the dashboard update (needs internet, then metered consent), shows minimal inline
9+
* progress (a description, a live one-line output tail, an indeterminate bar and a Cancel)
10+
* injected right after the trigger view, runs the box refresh on an IO thread, and reports
11+
* the outcome in a snackbar.
12+
*
13+
* Lifecycle: there is NO persistent app-side state. The box refresh job is detached
14+
* (setsid), so a host that goes away mid-run just drops the UI updates (guarded by
15+
* View.isAttachedToWindow()); the box finishes on its own and the tree is swapped in only
16+
* on success. The only state is the box's own status/pid files, which the box manages.
17+
* ============================================================================
18+
*/
19+
package org.appdevforall.k2go.codeassets.presentation;
20+
21+
import android.app.Activity;
22+
import android.content.Context;
23+
import android.os.Handler;
24+
import android.os.Looper;
25+
import android.text.TextUtils;
26+
import android.view.Gravity;
27+
import android.view.View;
28+
import android.view.ViewGroup;
29+
import android.widget.LinearLayout;
30+
import android.widget.TextView;
31+
32+
import androidx.annotation.NonNull;
33+
import androidx.core.content.ContextCompat;
34+
35+
import com.google.android.material.progressindicator.LinearProgressIndicator;
36+
37+
import org.appdevforall.k2go.R;
38+
import org.appdevforall.k2go.codeassets.data.CodeAssetsRefreshClient;
39+
import org.appdevforall.k2go.util.AppExecutors;
40+
import org.appdevforall.k2go.util.Snackbars;
41+
42+
public final class CodeAssetsRefresh {
43+
44+
private CodeAssetsRefresh() {}
45+
46+
/**
47+
* Gate (internet, then metered consent) then run the refresh with progress injected right after
48+
* {@code trigger}. The trigger stays in place (only disabled) as a visible anchor for the snackbar.
49+
*/
50+
public static void start(@NonNull Activity act, @NonNull View trigger) {
51+
if (!org.appdevforall.k2go.networkpolicy.data.AndroidNetworkClassifier.hasInternet(act)) {
52+
Snackbars.make(trigger, act.getString(R.string.k2go_dash_needs_internet)).show();
53+
return;
54+
}
55+
// Same metered gate as the dashboard update: the box downloads the build assets over the device network.
56+
org.appdevforall.k2go.networkpolicy.presentation.NetworkPolicyGate.guardHeavyStart(act, () -> run(trigger));
57+
}
58+
59+
private static void run(@NonNull View trigger) {
60+
final ViewGroup parent = (ViewGroup) trigger.getParent();
61+
if (parent == null || !trigger.isAttachedToWindow()) return; // host went away during the gate
62+
final Context ctx = trigger.getContext();
63+
final Handler main = new Handler(Looper.getMainLooper());
64+
final float d = ctx.getResources().getDisplayMetrics().density;
65+
final int side = Math.round(20 * d);
66+
67+
final LinearLayout progress = new LinearLayout(ctx);
68+
progress.setOrientation(LinearLayout.VERTICAL);
69+
LinearLayout.LayoutParams plp = new LinearLayout.LayoutParams(
70+
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
71+
plp.leftMargin = side; plp.rightMargin = side; plp.topMargin = Math.round(8 * d);
72+
progress.setLayoutParams(plp);
73+
74+
final TextView label = new TextView(ctx);
75+
label.setText(R.string.k2go_code_assets_updating);
76+
label.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall);
77+
label.setTextColor(ContextCompat.getColor(ctx, R.color.k2go_muted));
78+
progress.addView(label);
79+
80+
final TextView liveLine = new TextView(ctx);
81+
liveLine.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall);
82+
liveLine.setTextColor(ContextCompat.getColor(ctx, R.color.k2go_muted));
83+
liveLine.setMaxLines(1);
84+
liveLine.setEllipsize(TextUtils.TruncateAt.END);
85+
LinearLayout.LayoutParams llp = new LinearLayout.LayoutParams(
86+
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
87+
llp.topMargin = Math.round(2 * d);
88+
liveLine.setLayoutParams(llp);
89+
progress.addView(liveLine);
90+
91+
final LinearLayout barLine = new LinearLayout(ctx);
92+
barLine.setOrientation(LinearLayout.HORIZONTAL);
93+
barLine.setGravity(Gravity.CENTER_VERTICAL);
94+
LinearLayout.LayoutParams barLineLp = new LinearLayout.LayoutParams(
95+
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
96+
barLineLp.topMargin = Math.round(4 * d);
97+
barLine.setLayoutParams(barLineLp);
98+
99+
final LinearProgressIndicator bar = new LinearProgressIndicator(ctx);
100+
bar.setIndeterminate(true);
101+
LinearLayout.LayoutParams blp = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f);
102+
bar.setLayoutParams(blp);
103+
barLine.addView(bar);
104+
105+
final TextView cancel = new TextView(ctx);
106+
cancel.setText(R.string.k2go_dash_cancel);
107+
cancel.setAllCaps(true);
108+
cancel.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelLarge);
109+
cancel.setTextColor(ContextCompat.getColor(ctx, R.color.k2go_teal));
110+
int hp = Math.round(12 * d), vp = Math.round(6 * d);
111+
cancel.setPadding(hp, vp, hp, vp);
112+
barLine.addView(cancel);
113+
progress.addView(barLine);
114+
115+
parent.addView(progress, parent.indexOfChild(trigger) + 1);
116+
trigger.setEnabled(false); // stays in place as an anchor; re-enabled when the refresh settles
117+
118+
cancel.setOnClickListener(cv -> {
119+
cancel.setEnabled(false);
120+
label.setText(R.string.k2go_code_assets_update_cancelling);
121+
AppExecutors.get().io().execute(() -> new CodeAssetsRefreshClient().cancel());
122+
});
123+
124+
AppExecutors.get().io().execute(() -> {
125+
final CodeAssetsRefreshClient client = new CodeAssetsRefreshClient();
126+
final CodeAssetsRefreshClient.Result r = client.refresh(rawLine -> {
127+
final String shown = rawLine.trim();
128+
main.post(() -> { if (liveLine.isAttachedToWindow()) liveLine.setText(shown); });
129+
});
130+
final int failed = client.lastFailed();
131+
final boolean upToDate = client.lastUpToDate();
132+
main.post(() -> {
133+
if (!trigger.isAttachedToWindow()) return;
134+
parent.removeView(progress);
135+
trigger.setEnabled(true);
136+
Snackbars.make(trigger, ctx.getString(messageFor(r, failed, upToDate))).show();
137+
});
138+
});
139+
}
140+
141+
/** Map the refresh outcome to a user message covering every state. */
142+
private static int messageFor(CodeAssetsRefreshClient.Result r, int failed, boolean upToDate) {
143+
if (r == CodeAssetsRefreshClient.Result.CANCELLED) return R.string.k2go_code_assets_update_cancelled;
144+
if (r != CodeAssetsRefreshClient.Result.DONE) return R.string.k2go_code_assets_update_failed; // box unreachable
145+
if (failed > 0) return R.string.k2go_code_assets_update_some_failed; // some files could not be fetched
146+
if (upToDate) return R.string.k2go_code_assets_update_none;
147+
return R.string.k2go_code_assets_update_done;
148+
}
149+
}

‎controller/app/src/main/java/org/appdevforall/k2go/dependency/domain/DashNodeRequirement.java‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ private DashNodeRequirement() {}
2727
public static int[] minFor(@Nullable String moduleKey) {
2828
if ("forgejo".equals(moduleKey)) return new int[]{1, 3, 7};
2929
if ("code_addons".equals(moduleKey)) return new int[]{1, 3, 8}; // K2GO-99: needs /addons/refresh
30+
// K2GO-437: code_assets install (runrole) and browsing (WebView) do NOT use the dash-node
31+
// refresh endpoints; only the "Update build assets" action does, and it degrades gracefully
32+
// (a 404 -> "could not update") on an older dash-node. So installing/viewing is NOT gated on
33+
// the dash-node version.
3034
return null;
3135
}
3236

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ private void populateCards() {
182182
cards.add(new Card("books", getString(R.string.k2go_card_books), false, R.drawable.ic_card_book));
183183
cards.add(new Card("code", getString(R.string.k2go_card_code), false, R.drawable.ic_card_code));
184184
cards.add(new Card("code-addons", getString(R.string.k2go_card_code_addons), false, R.drawable.ic_card_code));
185+
cards.add(new Card("code-assets", getString(R.string.k2go_card_code_assets), false, R.drawable.ic_card_code));
185186
cards.add(new Card("forgejo", getString(R.string.k2go_card_forgejo), false, R.drawable.ic_card_forgejo));
186187
cards.add(new Card("kiwix", getString(R.string.k2go_card_wikipedia), true, R.drawable.ic_card_wikipedia));
187188
cards.add(new Card("kolibri", getString(R.string.k2go_card_courses), false, R.drawable.ic_card_courses));

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,12 @@ public static void show(Activity act, String endpoint, String title, int iconRes
231231
act.getString(R.string.k2go_code_addons_update), Emphasis.ACCENT, null, false,
232232
v -> org.appdevforall.k2go.addons.presentation.AddonsRefresh.start(act, v)));
233233
}
234+
// K2GO-437: an installed build-assets tree offers the same live refresh here.
235+
if ("code_assets".equals(key)) {
236+
content.addView(row(ctx, R.drawable.ic_refresh,
237+
act.getString(R.string.k2go_code_assets_update), Emphasis.ACCENT, null, false,
238+
v -> org.appdevforall.k2go.codeassets.presentation.CodeAssetsRefresh.start(act, v)));
239+
}
234240
break;
235241
case SCHEDULED: {
236242
content.addView(about);

0 commit comments

Comments
 (0)