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 @@ -39,8 +39,8 @@
*/
public final class ForgejoSeedClient {

/** Terminal verdict of a drive. */
public enum Result { DONE, ERROR }
/** Terminal verdict of a drive. CANCELLED only applies to a refresh the user stopped. */
public enum Result { DONE, ERROR, CANCELLED }

/** Streamed status-tail lines, for a log/notification. Optional (pass null to ignore). */
public interface Listener {
Expand All @@ -49,6 +49,10 @@ public interface Listener {

private static final String SEED_URL = BoxEndpoints.API + "/forgejo/seed";
private static final String STATUS_URL = BoxEndpoints.API + "/forgejo/seed/status";
// K2GO-422: the repo refresh reuses this same POST-then-poll shape against its own endpoints.
private static final String REFRESH_URL = BoxEndpoints.API + "/forgejo/refresh";
private static final String REFRESH_STATUS_URL = BoxEndpoints.API + "/forgejo/refresh/status";
private static final String REFRESH_CANCEL_URL = BoxEndpoints.API + "/forgejo/refresh/cancel";
private static final long POLL_MS = 2000L;
private static final int MAX_POLL_ERRORS = 15; // ~30s of transient blips before giving up
// A seed (repo clones ~113 MB) can run for minutes; cap the wait so a wedged box job cannot
Expand All @@ -57,6 +61,15 @@ public interface Listener {

/** The last status-tail line handed to the listener, so a poll that did not advance stays quiet. */
private String lastEmitted;
// K2GO-422: last per-repo outcome counts reported by a refresh status (-1 = not reported / unknown).
private int lastChanged = -1, lastProblems = -1, lastTotal = -1;

/** Repos that advanced in the last refresh (fast-forward or merge); -1 if the box did not report it. */
public int lastChanged() { return lastChanged; }
/** Repos the last refresh could not update (conflict or a fetch/push failure); -1 if not reported. */
public int lastProblems() { return lastProblems; }
/** Repos the last refresh attempted; -1 if the box did not report it. */
public int lastTotal() { return lastTotal; }

/**
* Start the seed if it is not already running, then poll to a terminal state.
Expand All @@ -79,35 +92,73 @@ public Result drive(boolean includeRepos, @Nullable Listener l) {
*/
@NonNull
public Result drive(boolean includeRepos, boolean force, @Nullable Listener l) {
String state = readState(l);
String state = readState(STATUS_URL, l);
if (!force && "done".equals(state)) return Result.DONE;
if (!"running".equals(state)) {
// idle / error / missing (or forced past a stale done) -> (re)start it. A 409 "already
// running" is fine: fall through to poll.
if (!postStart(includeRepos)) return Result.ERROR;
if (!postSeed(includeRepos)) return Result.ERROR;
}
return poll(STATUS_URL, l);
}

/**
* K2GO-422: drive a repo REFRESH to a terminal state. Same POST-then-poll shape as the seed, on the
* refresh endpoints. 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 non-destructive (fast-forward
* or clean merge, skip on conflict), so re-running is safe. Returns DONE when the box refresh
* finished, ERROR on an unreachable box or timeout.
*/
@NonNull
public Result refresh(@Nullable Listener l) {
String state = readState(REFRESH_STATUS_URL, l);
if (!"running".equals(state)) {
if (!post(REFRESH_URL, null)) return Result.ERROR;
}
return poll(REFRESH_STATUS_URL, l);
}

/** Poll one status endpoint to a terminal state, streaming new tail lines through the listener. */
@NonNull
private Result poll(@NonNull String statusUrl, @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);
String s = readState(statusUrl, 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; // K2GO-422: user stopped a refresh
// "running" (or an unknown transient) -> keep polling.
}
return Result.ERROR; // timed out; the box job may still finish, a later drive re-checks
}

/** POST /forgejo/seed; true if the box accepted the start (2xx) or it is already running (409). */
private boolean postStart(boolean includeRepos) {
/** K2GO-422: ask the box to stop a running refresh (best-effort; a poll then reads "cancelled"). */
public void cancelRefresh() {
post(REFRESH_CANCEL_URL, null);
}

/** POST /forgejo/seed with the includeRepos body; true on 2xx or 409 (already running). */
private boolean postSeed(boolean includeRepos) {
try {
JSONObject body = new JSONObject().put("includeRepos", includeRepos);
HttpURLConnection c = open("POST", SEED_URL);
c.setDoOutput(true);
c.setRequestProperty("Content-Type", "application/json");
try (OutputStream os = c.getOutputStream()) { os.write(body.toString().getBytes(StandardCharsets.UTF_8)); }
return post(SEED_URL, new JSONObject().put("includeRepos", includeRepos));
} catch (org.json.JSONException e) {
return false;
}
}

/** POST a start endpoint (optional JSON body); true if accepted (2xx) or already running (409). */
private boolean post(@NonNull String url, @Nullable JSONObject body) {
try {
HttpURLConnection c = open("POST", url);
if (body != null) {
c.setDoOutput(true);
c.setRequestProperty("Content-Type", "application/json");
try (OutputStream os = c.getOutputStream()) { os.write(body.toString().getBytes(StandardCharsets.UTF_8)); }
}
int code = c.getResponseCode();
c.disconnect();
return (code >= 200 && code < 300) || code == 409;
Expand All @@ -116,17 +167,21 @@ private boolean postStart(boolean includeRepos) {
}
}

/** GET /forgejo/seed/status -> the state string, streaming any new tail lines; null on a read error. */
/** GET a status endpoint -> the state string, streaming any new tail lines; null on a read error. */
@Nullable
private String readState(@Nullable Listener l) {
private String readState(@NonNull String statusUrl, @Nullable Listener l) {
try {
HttpURLConnection c = open("GET", STATUS_URL);
HttpURLConnection c = open("GET", statusUrl);
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);
// K2GO-422: capture the refresh outcome counts when present (the seed status omits them).
if (j.has("changed")) lastChanged = j.optInt("changed", lastChanged);
if (j.has("problems")) lastProblems = j.optInt("problems", lastProblems);
if (j.has("total")) lastTotal = j.optInt("total", lastTotal);
if (l != null) {
JSONArray lines = j.optJSONArray("lines");
if (lines != null && lines.length() > 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ public static final class Status {
/** The example repos are present -> nothing to install (refresh/update is K2GO-422 section A). */
public boolean hasRepos() { return repoCount > 0; }

/** K2Go can administer the forge AND example repos exist -> offer "Update repos". Gated on
* manageable so a forge we cannot authenticate (changed admin password) never offers a refresh
* that would 401; a blocked forge shows the blocked note instead. */
public boolean canUpdateRepos() { return manageable && repoCount > 0; }

/** An admin exists that K2Go cannot authenticate -> do not offer any repo action. */
public boolean blocked() { return !manageable; }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/*
* ============================================================================
* Name : ForgejoRepoRefresh.java
* Author : AppDevForAll
* Copyright : Copyright (c) 2026 AppDevForAll
* Description : K2GO-422. The single "Update repos" flow, shared by the module detail button and the
* module action sheet row so neither duplicates it. 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 (the sheet dismissed, the fragment detached)
* just drops the UI updates (guarded by View.isAttachedToWindow()); the box finishes on its
* own and the next /forgejo/status read reflects reality. The only state is the box's own
* status/pid files, which the box manages. So there is nothing here to leak or to clear.
* ============================================================================
*/
package org.appdevforall.k2go.forgejo.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.forgejo.data.ForgejoSeedClient;
import org.appdevforall.k2go.util.AppExecutors;
import org.appdevforall.k2go.util.Snackbars;

public final class ForgejoRepoRefresh {

private ForgejoRepoRefresh() {}

/**
* 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 git-fetches 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);

// Description of what is happening (static), then a live line that tails the box output one line at
// a time (the per-repo outcomes advance like the proot rows: it advances, not a log box).
final TextView label = new TextView(ctx);
label.setText(R.string.k2go_forgejo_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);

// The bar and Cancel share one line: the bar takes the width, Cancel sits beside it.
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_forgejo_update_cancelling);
AppExecutors.get().io().execute(() -> new ForgejoSeedClient().cancelRefresh());
});

AppExecutors.get().io().execute(() -> {
final ForgejoSeedClient client = new ForgejoSeedClient();
final ForgejoSeedClient.Result r = client.refresh(rawLine -> {
// Tail one line at a time, trimmed and without the org prefix, so it reads cleanly.
final String shown = rawLine.trim().replace("AppDevForAll/", "");
main.post(() -> { if (liveLine.isAttachedToWindow()) liveLine.setText(shown); });
});
// The box reports per-repo outcome counts on the refresh status (advanced / could-not / total).
final int changed = client.lastChanged();
final int problems = client.lastProblems();
final int total = client.lastTotal();
main.post(() -> {
if (!trigger.isAttachedToWindow()) return;
parent.removeView(progress);
trigger.setEnabled(true);
Snackbars.make(trigger, ctx.getString(messageFor(r, changed, problems, total))).show();
});
});
}

/** Map the refresh outcome to a user message covering every state. */
private static int messageFor(ForgejoSeedClient.Result r, int changed, int problems, int total) {
if (r == ForgejoSeedClient.Result.CANCELLED) return R.string.k2go_forgejo_update_cancelled;
if (r != ForgejoSeedClient.Result.DONE) return R.string.k2go_forgejo_update_failed; // box unreachable
if (problems > 0) {
// some repos could not be updated (conflict or a fetch/push failure); reconcile in the web UI
return (total - problems > 0) ? R.string.k2go_forgejo_update_some_failed
: R.string.k2go_forgejo_update_all_failed;
}
if (changed > 0) return R.string.k2go_forgejo_update_done; // at least one repo advanced
if (changed == 0) return R.string.k2go_forgejo_update_none; // nothing to update
return R.string.k2go_forgejo_update_done; // unknown counts (older box)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,27 @@ public static void show(Activity act, String endpoint, String title, int iconRes
content.addView(row(ctx, R.drawable.ic_arrow_right, act.getString(R.string.k2go_sheet_open),
Emphasis.ACCENT, null, false, v -> { dlg.dismiss(); openContent(act, endpoint); }));
content.addView(about);
// K2GO-422: an installed forgejo with the example repos present -> offer "Update repos"
// here (the module action menu). Gated by the SAME status the detail uses (hasRepos), read
// off the main thread; the row is inserted after About when the box answers, if the sheet
// is still up. Tapping runs the shared refresh IN the sheet (it does NOT dismiss).
if ("forgejo".equals(key)) {
final LinearLayout host = content;
org.appdevforall.k2go.util.AppExecutors.get().io().execute(() -> {
final org.appdevforall.k2go.forgejo.data.ForgejoStatusClient.Status st =
new org.appdevforall.k2go.forgejo.data.ForgejoStatusClient().fetch();
// Gate on canUpdateRepos (manageable AND repos present): a null read (cannot tell),
// no repos, or an admin we cannot authenticate (blocked) shows no Update row.
if (st == null || !st.canUpdateRepos()) return;
act.runOnUiThread(() -> {
if (!dlg.isShowing()) return;
View urow = row(ctx, R.drawable.ic_refresh,
act.getString(R.string.k2go_forgejo_update_repos), Emphasis.ACCENT, null, false,
v -> org.appdevforall.k2go.forgejo.presentation.ForgejoRepoRefresh.start(act, v));
host.addView(urow, host.indexOfChild(about) + 1);
});
});
}
break;
case SCHEDULED: {
content.addView(about);
Expand Down
Loading
Loading