Skip to content

Commit 8f5860b

Browse files
Merge pull request #595 from appdevforall/feat/K2GO-422-forgejo-repos-post-install
K2GO-422 feat(forgejo): non-destructive live repo refresh + "Update repos" action
2 parents 7600950 + 9676d05 commit 8f5860b

45 files changed

Lines changed: 813 additions & 18 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/forgejo/data/ForgejoSeedClient.java‎

Lines changed: 70 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@
3939
*/
4040
public final class ForgejoSeedClient {
4141

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

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

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

5862
/** The last status-tail line handed to the listener, so a poll that did not advance stays quiet. */
5963
private String lastEmitted;
64+
// K2GO-422: last per-repo outcome counts reported by a refresh status (-1 = not reported / unknown).
65+
private int lastChanged = -1, lastProblems = -1, lastTotal = -1;
66+
67+
/** Repos that advanced in the last refresh (fast-forward or merge); -1 if the box did not report it. */
68+
public int lastChanged() { return lastChanged; }
69+
/** Repos the last refresh could not update (conflict or a fetch/push failure); -1 if not reported. */
70+
public int lastProblems() { return lastProblems; }
71+
/** Repos the last refresh attempted; -1 if the box did not report it. */
72+
public int lastTotal() { return lastTotal; }
6073

6174
/**
6275
* Start the seed if it is not already running, then poll to a terminal state.
@@ -79,35 +92,73 @@ public Result drive(boolean includeRepos, @Nullable Listener l) {
7992
*/
8093
@NonNull
8194
public Result drive(boolean includeRepos, boolean force, @Nullable Listener l) {
82-
String state = readState(l);
95+
String state = readState(STATUS_URL, l);
8396
if (!force && "done".equals(state)) return Result.DONE;
8497
if (!"running".equals(state)) {
8598
// idle / error / missing (or forced past a stale done) -> (re)start it. A 409 "already
8699
// running" is fine: fall through to poll.
87-
if (!postStart(includeRepos)) return Result.ERROR;
100+
if (!postSeed(includeRepos)) return Result.ERROR;
101+
}
102+
return poll(STATUS_URL, l);
103+
}
104+
105+
/**
106+
* K2GO-422: drive a repo REFRESH to a terminal state. Same POST-then-poll shape as the seed, on the
107+
* refresh endpoints. Each refresh is intentional, so there is NO "done" short-circuit: unless one is
108+
* already running (re-attach), it POSTs a fresh run. The box refresh is non-destructive (fast-forward
109+
* or clean merge, skip on conflict), so re-running is safe. Returns DONE when the box refresh
110+
* finished, ERROR on an unreachable box or timeout.
111+
*/
112+
@NonNull
113+
public Result refresh(@Nullable Listener l) {
114+
String state = readState(REFRESH_STATUS_URL, l);
115+
if (!"running".equals(state)) {
116+
if (!post(REFRESH_URL, null)) return Result.ERROR;
88117
}
118+
return poll(REFRESH_STATUS_URL, l);
119+
}
120+
121+
/** Poll one status endpoint to a terminal state, streaming new tail lines through the listener. */
122+
@NonNull
123+
private Result poll(@NonNull String statusUrl, @Nullable Listener l) {
89124
final long deadline = System.currentTimeMillis() + MAX_WAIT_MS;
90125
int pollErrors = 0;
91126
while (System.currentTimeMillis() < deadline) {
92127
try { Thread.sleep(POLL_MS); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); return Result.ERROR; }
93-
String s = readState(l);
128+
String s = readState(statusUrl, l);
94129
if (s == null) { if (++pollErrors > MAX_POLL_ERRORS) return Result.ERROR; continue; }
95130
pollErrors = 0;
96131
if ("done".equals(s)) return Result.DONE;
97132
if ("error".equals(s)) return Result.ERROR;
133+
if ("cancelled".equals(s)) return Result.CANCELLED; // K2GO-422: user stopped a refresh
98134
// "running" (or an unknown transient) -> keep polling.
99135
}
100136
return Result.ERROR; // timed out; the box job may still finish, a later drive re-checks
101137
}
102138

103-
/** POST /forgejo/seed; true if the box accepted the start (2xx) or it is already running (409). */
104-
private boolean postStart(boolean includeRepos) {
139+
/** K2GO-422: ask the box to stop a running refresh (best-effort; a poll then reads "cancelled"). */
140+
public void cancelRefresh() {
141+
post(REFRESH_CANCEL_URL, null);
142+
}
143+
144+
/** POST /forgejo/seed with the includeRepos body; true on 2xx or 409 (already running). */
145+
private boolean postSeed(boolean includeRepos) {
105146
try {
106-
JSONObject body = new JSONObject().put("includeRepos", includeRepos);
107-
HttpURLConnection c = open("POST", SEED_URL);
108-
c.setDoOutput(true);
109-
c.setRequestProperty("Content-Type", "application/json");
110-
try (OutputStream os = c.getOutputStream()) { os.write(body.toString().getBytes(StandardCharsets.UTF_8)); }
147+
return post(SEED_URL, new JSONObject().put("includeRepos", includeRepos));
148+
} catch (org.json.JSONException e) {
149+
return false;
150+
}
151+
}
152+
153+
/** POST a start endpoint (optional JSON body); true if accepted (2xx) or already running (409). */
154+
private boolean post(@NonNull String url, @Nullable JSONObject body) {
155+
try {
156+
HttpURLConnection c = open("POST", url);
157+
if (body != null) {
158+
c.setDoOutput(true);
159+
c.setRequestProperty("Content-Type", "application/json");
160+
try (OutputStream os = c.getOutputStream()) { os.write(body.toString().getBytes(StandardCharsets.UTF_8)); }
161+
}
111162
int code = c.getResponseCode();
112163
c.disconnect();
113164
return (code >= 200 && code < 300) || code == 409;
@@ -116,17 +167,21 @@ private boolean postStart(boolean includeRepos) {
116167
}
117168
}
118169

119-
/** GET /forgejo/seed/status -> the state string, streaming any new tail lines; null on a read error. */
170+
/** GET a status endpoint -> the state string, streaming any new tail lines; null on a read error. */
120171
@Nullable
121-
private String readState(@Nullable Listener l) {
172+
private String readState(@NonNull String statusUrl, @Nullable Listener l) {
122173
try {
123-
HttpURLConnection c = open("GET", STATUS_URL);
174+
HttpURLConnection c = open("GET", statusUrl);
124175
int code = c.getResponseCode();
125176
boolean ok = code >= 200 && code < 400;
126177
String text = readAll(ok ? c.getInputStream() : c.getErrorStream());
127178
c.disconnect();
128179
if (!ok) return null;
129180
JSONObject j = new JSONObject(text.isEmpty() ? "{}" : text);
181+
// K2GO-422: capture the refresh outcome counts when present (the seed status omits them).
182+
if (j.has("changed")) lastChanged = j.optInt("changed", lastChanged);
183+
if (j.has("problems")) lastProblems = j.optInt("problems", lastProblems);
184+
if (j.has("total")) lastTotal = j.optInt("total", lastTotal);
130185
if (l != null) {
131186
JSONArray lines = j.optJSONArray("lines");
132187
if (lines != null && lines.length() > 0) {

‎controller/app/src/main/java/org/appdevforall/k2go/forgejo/data/ForgejoStatusClient.java‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ public static final class Status {
5252
/** The example repos are present -> nothing to install (refresh/update is K2GO-422 section A). */
5353
public boolean hasRepos() { return repoCount > 0; }
5454

55+
/** K2Go can administer the forge AND example repos exist -> offer "Update repos". Gated on
56+
* manageable so a forge we cannot authenticate (changed admin password) never offers a refresh
57+
* that would 401; a blocked forge shows the blocked note instead. */
58+
public boolean canUpdateRepos() { return manageable && repoCount > 0; }
59+
5560
/** An admin exists that K2Go cannot authenticate -> do not offer any repo action. */
5661
public boolean blocked() { return !manageable; }
5762
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/*
2+
* ============================================================================
3+
* Name : ForgejoRepoRefresh.java
4+
* Author : AppDevForAll
5+
* Copyright : Copyright (c) 2026 AppDevForAll
6+
* Description : K2GO-422. The single "Update repos" flow, shared by the module detail button and the
7+
* module action sheet row so neither duplicates it. It gates like the dashboard update
8+
* (needs internet, then metered consent), shows minimal inline progress (a description, a
9+
* live one-line output tail, an indeterminate bar and a Cancel) injected right after the
10+
* trigger view, runs the box refresh on an IO thread, and reports the outcome in a snackbar.
11+
*
12+
* Lifecycle: there is NO persistent app-side state. The box refresh job is detached
13+
* (setsid), so a host that goes away mid-run (the sheet dismissed, the fragment detached)
14+
* just drops the UI updates (guarded by View.isAttachedToWindow()); the box finishes on its
15+
* own and the next /forgejo/status read reflects reality. The only state is the box's own
16+
* status/pid files, which the box manages. So there is nothing here to leak or to clear.
17+
* ============================================================================
18+
*/
19+
package org.appdevforall.k2go.forgejo.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.forgejo.data.ForgejoSeedClient;
39+
import org.appdevforall.k2go.util.AppExecutors;
40+
import org.appdevforall.k2go.util.Snackbars;
41+
42+
public final class ForgejoRepoRefresh {
43+
44+
private ForgejoRepoRefresh() {}
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 git-fetches 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+
// Description of what is happening (static), then a live line that tails the box output one line at
75+
// a time (the per-repo outcomes advance like the proot rows: it advances, not a log box).
76+
final TextView label = new TextView(ctx);
77+
label.setText(R.string.k2go_forgejo_updating);
78+
label.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall);
79+
label.setTextColor(ContextCompat.getColor(ctx, R.color.k2go_muted));
80+
progress.addView(label);
81+
82+
final TextView liveLine = new TextView(ctx);
83+
liveLine.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall);
84+
liveLine.setTextColor(ContextCompat.getColor(ctx, R.color.k2go_muted));
85+
liveLine.setMaxLines(1);
86+
liveLine.setEllipsize(TextUtils.TruncateAt.END);
87+
LinearLayout.LayoutParams llp = new LinearLayout.LayoutParams(
88+
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
89+
llp.topMargin = Math.round(2 * d);
90+
liveLine.setLayoutParams(llp);
91+
progress.addView(liveLine);
92+
93+
// The bar and Cancel share one line: the bar takes the width, Cancel sits beside it.
94+
final LinearLayout barLine = new LinearLayout(ctx);
95+
barLine.setOrientation(LinearLayout.HORIZONTAL);
96+
barLine.setGravity(Gravity.CENTER_VERTICAL);
97+
LinearLayout.LayoutParams barLineLp = new LinearLayout.LayoutParams(
98+
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
99+
barLineLp.topMargin = Math.round(4 * d);
100+
barLine.setLayoutParams(barLineLp);
101+
102+
final LinearProgressIndicator bar = new LinearProgressIndicator(ctx);
103+
bar.setIndeterminate(true);
104+
LinearLayout.LayoutParams blp = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f);
105+
bar.setLayoutParams(blp);
106+
barLine.addView(bar);
107+
108+
final TextView cancel = new TextView(ctx);
109+
cancel.setText(R.string.k2go_dash_cancel);
110+
cancel.setAllCaps(true);
111+
cancel.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelLarge);
112+
cancel.setTextColor(ContextCompat.getColor(ctx, R.color.k2go_teal));
113+
int hp = Math.round(12 * d), vp = Math.round(6 * d);
114+
cancel.setPadding(hp, vp, hp, vp);
115+
barLine.addView(cancel);
116+
progress.addView(barLine);
117+
118+
parent.addView(progress, parent.indexOfChild(trigger) + 1);
119+
trigger.setEnabled(false); // stays in place as an anchor; re-enabled when the refresh settles
120+
121+
cancel.setOnClickListener(cv -> {
122+
cancel.setEnabled(false);
123+
label.setText(R.string.k2go_forgejo_update_cancelling);
124+
AppExecutors.get().io().execute(() -> new ForgejoSeedClient().cancelRefresh());
125+
});
126+
127+
AppExecutors.get().io().execute(() -> {
128+
final ForgejoSeedClient client = new ForgejoSeedClient();
129+
final ForgejoSeedClient.Result r = client.refresh(rawLine -> {
130+
// Tail one line at a time, trimmed and without the org prefix, so it reads cleanly.
131+
final String shown = rawLine.trim().replace("AppDevForAll/", "");
132+
main.post(() -> { if (liveLine.isAttachedToWindow()) liveLine.setText(shown); });
133+
});
134+
// The box reports per-repo outcome counts on the refresh status (advanced / could-not / total).
135+
final int changed = client.lastChanged();
136+
final int problems = client.lastProblems();
137+
final int total = client.lastTotal();
138+
main.post(() -> {
139+
if (!trigger.isAttachedToWindow()) return;
140+
parent.removeView(progress);
141+
trigger.setEnabled(true);
142+
Snackbars.make(trigger, ctx.getString(messageFor(r, changed, problems, total))).show();
143+
});
144+
});
145+
}
146+
147+
/** Map the refresh outcome to a user message covering every state. */
148+
private static int messageFor(ForgejoSeedClient.Result r, int changed, int problems, int total) {
149+
if (r == ForgejoSeedClient.Result.CANCELLED) return R.string.k2go_forgejo_update_cancelled;
150+
if (r != ForgejoSeedClient.Result.DONE) return R.string.k2go_forgejo_update_failed; // box unreachable
151+
if (problems > 0) {
152+
// some repos could not be updated (conflict or a fetch/push failure); reconcile in the web UI
153+
return (total - problems > 0) ? R.string.k2go_forgejo_update_some_failed
154+
: R.string.k2go_forgejo_update_all_failed;
155+
}
156+
if (changed > 0) return R.string.k2go_forgejo_update_done; // at least one repo advanced
157+
if (changed == 0) return R.string.k2go_forgejo_update_none; // nothing to update
158+
return R.string.k2go_forgejo_update_done; // unknown counts (older box)
159+
}
160+
}

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,27 @@ public static void show(Activity act, String endpoint, String title, int iconRes
202202
content.addView(row(ctx, R.drawable.ic_arrow_right, act.getString(R.string.k2go_sheet_open),
203203
Emphasis.ACCENT, null, false, v -> { dlg.dismiss(); openContent(act, endpoint); }));
204204
content.addView(about);
205+
// K2GO-422: an installed forgejo with the example repos present -> offer "Update repos"
206+
// here (the module action menu). Gated by the SAME status the detail uses (hasRepos), read
207+
// off the main thread; the row is inserted after About when the box answers, if the sheet
208+
// is still up. Tapping runs the shared refresh IN the sheet (it does NOT dismiss).
209+
if ("forgejo".equals(key)) {
210+
final LinearLayout host = content;
211+
org.appdevforall.k2go.util.AppExecutors.get().io().execute(() -> {
212+
final org.appdevforall.k2go.forgejo.data.ForgejoStatusClient.Status st =
213+
new org.appdevforall.k2go.forgejo.data.ForgejoStatusClient().fetch();
214+
// Gate on canUpdateRepos (manageable AND repos present): a null read (cannot tell),
215+
// no repos, or an admin we cannot authenticate (blocked) shows no Update row.
216+
if (st == null || !st.canUpdateRepos()) return;
217+
act.runOnUiThread(() -> {
218+
if (!dlg.isShowing()) return;
219+
View urow = row(ctx, R.drawable.ic_refresh,
220+
act.getString(R.string.k2go_forgejo_update_repos), Emphasis.ACCENT, null, false,
221+
v -> org.appdevforall.k2go.forgejo.presentation.ForgejoRepoRefresh.start(act, v));
222+
host.addView(urow, host.indexOfChild(about) + 1);
223+
});
224+
});
225+
}
205226
break;
206227
case SCHEDULED: {
207228
content.addView(about);

0 commit comments

Comments
 (0)