Skip to content

Commit e61f281

Browse files
Merge pull request #357 from appdevforall/feat/ADFA-5051-dashboard-live-self-update-1.2.0
ADFA-5051: dash-node 1.2.0 live self-update + version-gated update routing
2 parents 8c86e6c + 64f3be3 commit e61f281

44 files changed

Lines changed: 514 additions & 49 deletions

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/iiab/controller/redesign/DashboardClient.java‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,56 @@ public final class DashboardClient {
3232
private DashboardClient() {}
3333

3434
private static final String URL_UPDATE_CHECK = BoxEndpoints.API + "/system/dashboard/update-check";
35+
private static final String URL_REBUILD = BoxEndpoints.API + "/system/dashboard/rebuild";
36+
private static final String URL_REBUILD_STATUS = BoxEndpoints.API + "/system/dashboard/rebuild/status";
3537
private static final Handler MAIN = new Handler(Looper.getMainLooper());
3638

3739
public interface UpdateCb {
3840
void onResult(String installed, String available, boolean updateAvailable);
3941
void onErr(String message);
4042
}
4143

44+
/** ADFA-5051: result of triggering the live REST rebuild. {@code alreadyRunning} = the box reported
45+
* a rebuild already in progress (HTTP 409) — the caller can just start polling status. */
46+
public interface RebuildStartCb {
47+
void onStarted(boolean alreadyRunning);
48+
void onErr(String message);
49+
}
50+
51+
/** ADFA-5051: current rebuild state from the box: idle | running | done | error. */
52+
public interface RebuildStatusCb {
53+
void onState(String state);
54+
void onErr(String message);
55+
}
56+
57+
/** ADFA-5051: trigger the in-server blue-green rebuild (POST). Fire-and-forget: the box returns 202
58+
* at once (or 409 if one is already running); the caller then polls {@link #rebuildStatus}. */
59+
public static void rebuildStart(RebuildStartCb cb) {
60+
AppExecutors.get().io().execute(() -> {
61+
int[] status = {0};
62+
try {
63+
httpPost(URL_REBUILD, status);
64+
MAIN.post(() -> cb.onStarted(false));
65+
} catch (Exception e) {
66+
if (status[0] == 409) { MAIN.post(() -> cb.onStarted(true)); return; }
67+
MAIN.post(() -> cb.onErr("could not start rebuild"));
68+
}
69+
});
70+
}
71+
72+
/** ADFA-5051: read the rebuild state file the script writes (idle/running/done/error). */
73+
public static void rebuildStatus(RebuildStatusCb cb) {
74+
AppExecutors.get().io().execute(() -> {
75+
try {
76+
JSONObject o = new JSONObject(httpGet(URL_REBUILD_STATUS));
77+
final String state = o.optString("state", "idle");
78+
MAIN.post(() -> cb.onState(state));
79+
} catch (Exception e) {
80+
MAIN.post(() -> cb.onErr("status unavailable"));
81+
}
82+
});
83+
}
84+
4285
/** Ask the box whether a newer dash-node build is available. Runs off the main thread; the result
4386
* (or an error, when the box is stopped/offline) is posted back to the UI. */
4487
public static void updateCheck(UpdateCb cb) {
@@ -74,6 +117,25 @@ private static String httpGet(String urlStr) throws Exception {
74117
}
75118
}
76119

120+
/** POST with no body; {@code statusOut[0]} receives the HTTP status so callers can map 409. The
121+
* trigger returns immediately (202), so a short read timeout is fine. */
122+
private static void httpPost(String urlStr, int[] statusOut) throws Exception {
123+
HttpURLConnection c = (HttpURLConnection) new URL(urlStr).openConnection();
124+
try {
125+
c.setUseCaches(false);
126+
c.setConnectTimeout(5000);
127+
c.setReadTimeout(10000);
128+
c.setRequestMethod("POST"); // bodyless trigger; no doOutput/body needed
129+
c.setRequestProperty("Accept", "application/json");
130+
int code = c.getResponseCode();
131+
statusOut[0] = code;
132+
readAll(code >= 200 && code < 400 ? c.getInputStream() : c.getErrorStream());
133+
if (code < 200 || code >= 400) throw new Exception("HTTP " + code);
134+
} finally {
135+
c.disconnect();
136+
}
137+
}
138+
77139
private static String readAll(InputStream is) throws Exception {
78140
if (is == null) return "";
79141
ByteArrayOutputStream buf = new ByteArrayOutputStream();

‎controller/app/src/main/java/org/iiab/controller/redesign/DashboardDetailFragment.java‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public class DashboardDetailFragment extends Fragment {
3737
private final Handler main = new Handler(Looper.getMainLooper());
3838
private ViewGroup chips; // FlowLayout in XML — typed as ViewGroup so it wraps chips to 2 lines
3939
private TextView statusChip; // ADFA-5026: live "Up to date / Update available" pill (restyled in place)
40+
private TextView versionChip; // ADFA-5051: "v<version>" chip, updated in place after a live update
4041
private Button rebuild; // de-emphasized when already on the latest
4142
private TextView rebuildHint; // "no rebuild needed" note, shown only when on the latest
4243

@@ -72,7 +73,7 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c
7273
// "Rebuild"; hide the secondary "Install now".
7374
rebuild = root.findViewById(R.id.k2go_moddet_schedule);
7475
rebuild.setText(R.string.k2go_dash_rebuild);
75-
rebuild.setOnClickListener(v -> DashboardRebuild.confirmAndStart(this, root));
76+
rebuild.setOnClickListener(v -> DashboardRebuild.confirmAndStart(this, root, this::refreshAfterLiveUpdate));
7677
root.findViewById(R.id.k2go_moddet_install_now).setVisibility(View.GONE);
7778
rebuildHint = buildRebuildHint(rebuild); // ADFA-5026: "no rebuild needed" note (hidden until on-latest)
7879

@@ -138,19 +139,31 @@ private TextView buildRebuildHint(Button rebuildBtn) {
138139
}
139140

140141
/** Read the installed version from the rootfs package.json on disk (authoritative, always present;
141-
* no network/proot) and, if found, prepend a "v<version>" chip. */
142+
* no network/proot) and show a "v<version>" chip. ADFA-5051: reuses the same chip on refresh so a
143+
* live update updates it in place instead of prepending a duplicate. */
142144
private void fetchVersionChip() {
143145
final Context ctx = requireContext().getApplicationContext();
144146
AppExecutors.get().io().execute(() -> {
145147
final String ver = DashboardVersion.installed(ctx);
146148
main.post(() -> {
147-
if (isAdded() && chips != null && ver != null) {
148-
chips.addView(chip("v" + ver, R.color.k2go_teal), 0);
149+
if (!isAdded() || chips == null || ver == null) return;
150+
if (versionChip == null) {
151+
versionChip = chip("v" + ver, R.color.k2go_teal);
152+
chips.addView(versionChip, 0);
153+
} else {
154+
styleChip(versionChip, "v" + ver, R.color.k2go_teal);
149155
}
150156
});
151157
});
152158
}
153159

160+
/** ADFA-5051: after a successful live (REST) update, refresh the version chip + update pill in
161+
* place so the card reflects the new version immediately (no need to leave and re-open). */
162+
private void refreshAfterLiveUpdate() {
163+
fetchVersionChip();
164+
fetchUpdateStatus();
165+
}
166+
154167
/** Small outlined pill for the meta-chip row (matches ModuleDetailFragment). */
155168
private TextView chip(String text, int colorRes) {
156169
float d = getResources().getDisplayMetrics().density;

‎controller/app/src/main/java/org/iiab/controller/redesign/DashboardRebuild.java‎

Lines changed: 131 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,34 +3,56 @@
33
* Name : DashboardRebuild.java
44
* Author : AppDevForAll
55
* Copyright : Copyright (c) 2026 AppDevForAll
6-
* Description : ADFA-5011. Single entry point for starting a dash-node REST-core rebuild, shared by
7-
* the Module-management hub row and the dashboard detail card so both offer the exact
8-
* same gated flow: busy check (EnvironmentLock) -> internet check -> confirm dialog ->
9-
* start the guarded InstallService rebuild + open the progress screen (which now stays
10-
* put until the rebuild reaches SUCCESS/FAILED).
6+
* Description : ADFA-5011 / ADFA-5051. Single entry point for starting a dash-node REST-core rebuild,
7+
* shared by the Module-management hub row and the dashboard detail card. Gated flow:
8+
* busy check (EnvironmentLock) -> internet check -> confirm dialog -> start.
9+
*
10+
* ADFA-5051 — version-gated cutover: from dash-node 1.2.0 the core updates ITSELF LIVE
11+
* over REST (POST /system/dashboard/rebuild: the blue-green rebuild that stages, smoke-
12+
* tests, atomically swaps and rolls back on failure — no rootfs/proot). Installs still
13+
* on < 1.2.0 predate that, so they take the heavier proot rebuild (InstallService) once
14+
* to reach 1.2.0; from there every later update is the live REST path.
1115
* ============================================================================
1216
*/
1317
package org.iiab.controller.redesign;
1418

1519
import android.content.Context;
1620
import android.content.Intent;
1721
import android.os.Build;
22+
import android.os.Handler;
23+
import android.os.Looper;
24+
import android.view.Gravity;
1825
import android.view.View;
26+
import android.widget.LinearLayout;
27+
import android.widget.TextView;
1928

2029
import androidx.annotation.NonNull;
30+
import androidx.annotation.Nullable;
31+
import androidx.appcompat.app.AlertDialog;
2132
import androidx.fragment.app.Fragment;
2233

2334
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
35+
import com.google.android.material.progressindicator.CircularProgressIndicator;
2436

2537
import org.iiab.controller.R;
2638
import org.iiab.controller.install.presentation.InstallService;
39+
import org.iiab.controller.util.AppExecutors;
2740
import org.iiab.controller.util.Snackbars;
2841

2942
public final class DashboardRebuild {
3043
private DashboardRebuild() {}
3144

32-
/** Gate then confirm then start. {@code anchor} is where a "busy"/"no internet" snackbar shows. */
33-
public static void confirmAndStart(@NonNull Fragment host, @NonNull View anchor) {
45+
// Live-rebuild poll cadence + overall cap. The blue-green rebuild does yarn install/build + smoke
46+
// test + a dash-node restart, so allow a generous ceiling; the restart briefly makes the status
47+
// endpoint unreachable, which we treat as "keep polling", not a failure.
48+
private static final long POLL_MS = 2500L;
49+
private static final int MAX_POLLS = 160; // ~6.5 min
50+
51+
/** Gate then confirm then start. {@code anchor} is where a "busy"/"no internet" snackbar shows.
52+
* {@code onLiveUpdated} (nullable) runs after a successful LIVE (REST) update so the caller can
53+
* refresh its version chip / update pill in place (ADFA-5051). */
54+
public static void confirmAndStart(@NonNull Fragment host, @NonNull View anchor,
55+
@Nullable Runnable onLiveUpdated) {
3456
Context ctx = host.requireContext();
3557
if (org.iiab.controller.env.EnvironmentLock.isHeld(ctx)) {
3658
Snackbars.make(anchor, R.string.k2go_install_busy).show();
@@ -44,13 +66,29 @@ public static void confirmAndStart(@NonNull Fragment host, @NonNull View anchor)
4466
.setTitle(R.string.k2go_dash_rebuild_confirm_title)
4567
.setMessage(R.string.k2go_dash_rebuild_confirm_msg)
4668
.setNegativeButton(android.R.string.cancel, null)
47-
.setPositiveButton(R.string.k2go_dash_rebuild, (d, w) -> start(host))
69+
.setPositiveButton(R.string.k2go_dash_rebuild, (d, w) -> start(host, anchor, onLiveUpdated))
4870
.show();
4971
}
5072

51-
/** Kick the foreground rebuild service and open the guarded progress screen (flagged as a rebuild
52-
* so it stays on the animation and blocks leaving until the rebuild finishes). */
53-
private static void start(@NonNull Fragment host) {
73+
/** ADFA-5051: route by the installed dash-node version. >= 1.2.0 updates live over REST; older
74+
* installs take the proot rebuild once as a bridge to 1.2.0. The version read hits disk, so it
75+
* runs off the main thread; the routing itself is posted back to the UI. */
76+
private static void start(@NonNull Fragment host, @NonNull View anchor, @Nullable Runnable onLiveUpdated) {
77+
final Context app = host.requireContext().getApplicationContext();
78+
final Handler main = new Handler(Looper.getMainLooper());
79+
AppExecutors.get().io().execute(() -> {
80+
final boolean rest = DashboardVersion.atLeast(DashboardVersion.installed(app), 1, 2, 0);
81+
main.post(() -> {
82+
if (!host.isAdded()) return;
83+
if (rest) startRest(host, anchor, onLiveUpdated);
84+
else startProot(host);
85+
});
86+
});
87+
}
88+
89+
/** Kick the foreground proot rebuild service and open the guarded progress screen (flagged as a
90+
* rebuild so it stays on the animation and blocks leaving until the rebuild finishes). */
91+
private static void startProot(@NonNull Fragment host) {
5492
Context ctx = host.requireContext();
5593
Intent svc = new Intent(ctx, InstallService.class)
5694
.setAction(InstallService.ACTION_REBUILD_DASHBOARD);
@@ -60,6 +98,88 @@ private static void start(@NonNull Fragment host) {
6098
.putExtra(SetupProgressActivity.EXTRA_REBUILD, true));
6199
}
62100

101+
/** ADFA-5051: live REST update. Trigger the in-server rebuild, then show a non-cancelable progress
102+
* dialog that polls the state until done/error (tolerating the brief restart window). */
103+
private static void startRest(@NonNull Fragment host, @NonNull View anchor, @Nullable Runnable onLiveUpdated) {
104+
Context ctx = host.requireContext();
105+
LinearLayout box = new LinearLayout(ctx);
106+
box.setOrientation(LinearLayout.HORIZONTAL);
107+
box.setGravity(Gravity.CENTER_VERTICAL);
108+
int pad = Math.round(24 * ctx.getResources().getDisplayMetrics().density);
109+
box.setPadding(pad, pad, pad, pad);
110+
CircularProgressIndicator spin = new CircularProgressIndicator(ctx);
111+
spin.setIndeterminate(true);
112+
box.addView(spin);
113+
TextView msg = new TextView(ctx);
114+
msg.setText(R.string.k2go_dash_live_running);
115+
LinearLayout.LayoutParams mlp = new LinearLayout.LayoutParams(-2, -2);
116+
mlp.leftMargin = pad;
117+
msg.setLayoutParams(mlp);
118+
box.addView(msg);
119+
120+
final AlertDialog dialog = new MaterialAlertDialogBuilder(ctx)
121+
.setTitle(R.string.k2go_dash_live_title)
122+
.setView(box)
123+
.setCancelable(false)
124+
.show();
125+
126+
final Handler poller = new Handler(Looper.getMainLooper());
127+
DashboardClient.rebuildStart(new DashboardClient.RebuildStartCb() {
128+
@Override public void onStarted(boolean alreadyRunning) {
129+
if (!host.isAdded()) { safeDismiss(dialog); return; }
130+
pollStatus(host, dialog, poller, new int[]{0}, onLiveUpdated);
131+
}
132+
@Override public void onErr(String message) {
133+
safeDismiss(dialog);
134+
if (host.isAdded()) Snackbars.make(anchor, R.string.k2go_dash_live_start_failed).show();
135+
}
136+
});
137+
}
138+
139+
/** Poll rebuild state until a terminal one, reusing a single {@code poller} Handler. A status error
140+
* is transient (dash-node restarts mid-swap) so we keep polling until MAX_POLLS. Hitting the cap is
141+
* NOT a failure: the rebuild runs detached server-side and may still finish, so we show a distinct
142+
* "still working in the background" message rather than the rollback/failure one. */
143+
private static void pollStatus(@NonNull Fragment host, @NonNull AlertDialog dialog,
144+
@NonNull Handler poller, int[] tries, @Nullable Runnable onLiveUpdated) {
145+
if (!host.isAdded()) { safeDismiss(dialog); return; }
146+
if (tries[0]++ >= MAX_POLLS) { finishRest(host, dialog, R.string.k2go_dash_live_timeout, onLiveUpdated); return; }
147+
DashboardClient.rebuildStatus(new DashboardClient.RebuildStatusCb() {
148+
@Override public void onState(String state) {
149+
if (!host.isAdded()) { safeDismiss(dialog); return; }
150+
if ("done".equals(state)) { finishRest(host, dialog, R.string.k2go_dash_live_done, onLiveUpdated); return; }
151+
if ("error".equals(state)) { finishRest(host, dialog, R.string.k2go_dash_live_error, onLiveUpdated); return; }
152+
schedule();
153+
}
154+
@Override public void onErr(String message) {
155+
if (!host.isAdded()) { safeDismiss(dialog); return; }
156+
schedule(); // API likely restarting mid-swap; keep waiting
157+
}
158+
private void schedule() {
159+
poller.postDelayed(() -> pollStatus(host, dialog, poller, tries, onLiveUpdated), POLL_MS);
160+
}
161+
});
162+
}
163+
164+
/** Swap the progress dialog for a simple result dialog carrying {@code msgRes} (done/error/timeout).
165+
* On success, fire {@code onLiveUpdated} (ADFA-5051) so the caller refreshes its version/pill in
166+
* place — otherwise the card keeps showing "update available" and users re-tap Rebuild. */
167+
private static void finishRest(@NonNull Fragment host, @NonNull AlertDialog dialog, int msgRes,
168+
@Nullable Runnable onLiveUpdated) {
169+
safeDismiss(dialog);
170+
if (!host.isAdded()) return;
171+
if (msgRes == R.string.k2go_dash_live_done && onLiveUpdated != null) onLiveUpdated.run();
172+
new MaterialAlertDialogBuilder(host.requireContext())
173+
.setTitle(R.string.k2go_dash_live_title)
174+
.setMessage(msgRes)
175+
.setPositiveButton(android.R.string.ok, null)
176+
.show();
177+
}
178+
179+
private static void safeDismiss(@NonNull AlertDialog d) {
180+
try { if (d.isShowing()) d.dismiss(); } catch (Exception ignore) { /* activity gone */ }
181+
}
182+
63183
/** True when the device reports an internet-capable active network. Unknown -> true (let the
64184
* preflight decide), matching the previous inline check in ModuleHubFragment. */
65185
public static boolean hasInternet(@NonNull Context ctx) {

‎controller/app/src/main/java/org/iiab/controller/redesign/DashboardVersion.java‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,21 @@
2727
public final class DashboardVersion {
2828
private DashboardVersion() {}
2929

30+
/** ADFA-5051: true when {@code version} (x.y.z, any suffix ignored) is >= major.minor.patch.
31+
* A null/unparseable version returns false, so callers default to the safe (proot) path. */
32+
public static boolean atLeast(@Nullable String version, int major, int minor, int patch) {
33+
if (version == null) return false;
34+
String core = version.split("[-+]", 2)[0]; // drop any "-beta"/"+build" suffix
35+
String[] p = core.split("\\.");
36+
int[] want = {major, minor, patch};
37+
for (int i = 0; i < 3; i++) {
38+
int have = 0;
39+
if (i < p.length) { try { have = Integer.parseInt(p[i].trim()); } catch (NumberFormatException e) { have = 0; } }
40+
if (have != want[i]) return have > want[i];
41+
}
42+
return true; // exactly equal
43+
}
44+
3045
/** Installed dash-node version from the rootfs package.json, or null if not found/parseable. */
3146
@Nullable
3247
public static String installed(@NonNull Context ctx) {

‎controller/app/src/main/java/org/iiab/controller/redesign/ModuleHubFragment.java‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ private void addSystemDashboardCard() {
356356

357357
TextView rebuild = statePill(getString(R.string.k2go_dash_rebuild), R.color.k2go_teal);
358358
rebuild.setPadding(px(14), px(6), px(14), px(6));
359-
rebuild.setOnClickListener(v -> DashboardRebuild.confirmAndStart(this, host));
359+
rebuild.setOnClickListener(v -> DashboardRebuild.confirmAndStart(this, host, null));
360360
LinearLayout.LayoutParams tlp = new LinearLayout.LayoutParams(
361361
LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
362362
tlp.leftMargin = px(10);

0 commit comments

Comments
 (0)