Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ public IiabModule(String endpoint, int nameResId, boolean requires64Bit, String
new IiabModule("kolibri", R.string.dash_kolibri, false, "kolibri"), // YAML uses kolibri_install
new IiabModule("maps", R.string.dash_maps, false, "maps"), // YAML uses maps_install
new IiabModule("matomo", R.string.dash_matomo, false, "matomo"), // YAML uses matomo_install
new IiabModule("forgejo", R.string.dash_forgejo, false, "forgejo") // YAML uses forgejo_install
new IiabModule("forgejo", R.string.dash_forgejo, false, "forgejo"), // YAML uses forgejo_install
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
// ADFA-4842: "dashboard" is intentionally NOT in the roster. It is no longer an
// application/module — it became the REST API core of K2Go; without it maps FQR and all
// REST content downloads fail. It is part of the core system, not a user-installable module.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/*
* ============================================================================
* Name : AddonsRefreshClient.java
* Author : AppDevForAll
* Copyright : Copyright (c) 2026 AppDevForAll
* Description : K2GO-99. App-side client of the dash-node add-ons refresh
* (static/dashboard routes.ts). The box re-mirrors the Code on the
* Go add-ons gallery while the server is UP: the device only POSTs
* to start and polls a coarse status, so a dropped app never stops
* the refresh (the box job is detached). Same POST-then-poll shape
* as ForgejoSeedClient.refresh(); the status carries downloaded and
* failed counts instead of per-repo outcomes.
*
* Contract:
* POST /k2go-api/addons/refresh -> 202 { state:"running" }
* GET /k2go-api/addons/refresh/status -> { state, lines, downloaded, failed }
* POST /k2go-api/addons/refresh/cancel -> { state:"cancelled" }
* ============================================================================
*/
package org.appdevforall.k2go.addons.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 add-ons 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 AddonsRefreshClient {

/** 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 + "/addons/refresh";
private static final String REFRESH_STATUS_URL = BoxEndpoints.API + "/addons/refresh/status";
private static final String REFRESH_CANCEL_URL = BoxEndpoints.API + "/addons/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 full set of .cgp files (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, lastFailed = -1;

/** Files downloaded in the last refresh; -1 if the box did not report it. */
public int lastDownloaded() { return lastDownloaded; }
/** Files the last refresh could not fetch or verify; -1 if the box did not report it. */
public int lastFailed() { return lastFailed; }

/**
* 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 gallery 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("failed")) lastFailed = j.optInt("failed", lastFailed);
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());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/*
* ============================================================================
* Name : AddonsRefresh.java
* Author : AppDevForAll
* Copyright : Copyright (c) 2026 AppDevForAll
* Description : K2GO-99. The single "Update add-ons" flow, shared by the module detail button and the
* module action sheet row so neither duplicates it. Modeled on ForgejoRepoRefresh: 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.
*
* 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 gallery is swapped in
* only on success. The only state is the box's own status/pid files, which the box manages.
* ============================================================================
*/
package org.appdevforall.k2go.addons.presentation;

import android.app.Activity;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.text.TextUtils;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import android.widget.TextView;

import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;

import com.google.android.material.progressindicator.LinearProgressIndicator;

import org.appdevforall.k2go.R;
import org.appdevforall.k2go.addons.data.AddonsRefreshClient;
import org.appdevforall.k2go.util.AppExecutors;
import org.appdevforall.k2go.util.Snackbars;

public final class AddonsRefresh {

private AddonsRefresh() {}

/**
* Gate (internet, then metered consent) then run the refresh 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) {
if (!org.appdevforall.k2go.networkpolicy.data.AndroidNetworkClassifier.hasInternet(act)) {
Snackbars.make(trigger, act.getString(R.string.k2go_dash_needs_internet)).show();
return;
}
// Same metered gate as the dashboard update: the box downloads the gallery over the device network.
org.appdevforall.k2go.networkpolicy.presentation.NetworkPolicyGate.guardHeavyStart(act, () -> run(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 Handler main = new Handler(Looper.getMainLooper());
final float d = ctx.getResources().getDisplayMetrics().density;
final int side = Math.round(20 * d);

final LinearLayout progress = new LinearLayout(ctx);
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);
label.setText(R.string.k2go_code_addons_updating);
label.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall);
label.setTextColor(ContextCompat.getColor(ctx, 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(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
llp.topMargin = Math.round(2 * d);
liveLine.setLayoutParams(llp);
progress.addView(liveLine);

final LinearLayout barLine = new LinearLayout(ctx);
barLine.setOrientation(LinearLayout.HORIZONTAL);
barLine.setGravity(Gravity.CENTER_VERTICAL);
LinearLayout.LayoutParams barLineLp = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
barLineLp.topMargin = Math.round(4 * d);
barLine.setLayoutParams(barLineLp);

final LinearProgressIndicator bar = new LinearProgressIndicator(ctx);
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);
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

cancel.setOnClickListener(cv -> {
cancel.setEnabled(false);
label.setText(R.string.k2go_code_addons_update_cancelling);
AppExecutors.get().io().execute(() -> new AddonsRefreshClient().cancel());
});

AppExecutors.get().io().execute(() -> {
final AddonsRefreshClient client = new AddonsRefreshClient();
final AddonsRefreshClient.Result r = client.refresh(rawLine -> {
final String shown = rawLine.trim();
main.post(() -> { if (liveLine.isAttachedToWindow()) liveLine.setText(shown); });
});
final int failed = client.lastFailed();
main.post(() -> {
if (!trigger.isAttachedToWindow()) return;
parent.removeView(progress);
trigger.setEnabled(true);
Snackbars.make(trigger, ctx.getString(messageFor(r, failed))).show();
});
});
}

/** Map the refresh outcome to a user message covering every state. */
private static int messageFor(AddonsRefreshClient.Result r, int failed) {
if (r == AddonsRefreshClient.Result.CANCELLED) return R.string.k2go_code_addons_update_cancelled;
if (r != AddonsRefreshClient.Result.DONE) return R.string.k2go_code_addons_update_failed; // box unreachable
if (failed > 0) return R.string.k2go_code_addons_update_some_failed; // some files could not be fetched
return R.string.k2go_code_addons_update_done;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
*
* Forgejo needs the dashboard build that ships its repo status/refresh endpoints (1.3.7):
* an older dash-node makes "Install repos"/"Update repos" and the seed break silently, so
* installing Forgejo is HARD-blocked below that version. Other modules have no hard minimum
* installing Forgejo is HARD-blocked below that version. Code on the Go add-ons (K2GO-99)
* needs the dashboard build that ships the /addons/refresh endpoints (1.3.8), so the gallery
* can update, so it is HARD-blocked below that version too. Other modules have no hard minimum
* (a soft "update the dashboard" suggestion applies to all installs, handled in the gate).
* ============================================================================
*/
Expand All @@ -24,6 +26,7 @@ private DashNodeRequirement() {}
@Nullable
public static int[] minFor(@Nullable String moduleKey) {
if ("forgejo".equals(moduleKey)) return new int[]{1, 3, 7};
if ("code_addons".equals(moduleKey)) return new int[]{1, 3, 8}; // K2GO-99: needs /addons/refresh
return null;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ private void populateCards() {
cards.clear();
cards.add(new Card("books", getString(R.string.k2go_card_books), false, R.drawable.ic_card_book));
cards.add(new Card("code", getString(R.string.k2go_card_code), false, R.drawable.ic_card_code));
cards.add(new Card("code-addons", getString(R.string.k2go_card_code_addons), false, R.drawable.ic_card_code));
cards.add(new Card("forgejo", getString(R.string.k2go_card_forgejo), false, R.drawable.ic_card_forgejo));
cards.add(new Card("kiwix", getString(R.string.k2go_card_wikipedia), true, R.drawable.ic_card_wikipedia));
cards.add(new Card("kolibri", getString(R.string.k2go_card_courses), false, R.drawable.ic_card_courses));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,14 @@ 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)));
}
break;
case SCHEDULED: {
content.addView(about);
Expand Down
Loading
Loading