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 @@ -32,13 +32,56 @@ public final class DashboardClient {
private DashboardClient() {}

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

public interface UpdateCb {
void onResult(String installed, String available, boolean updateAvailable);
void onErr(String message);
}

/** ADFA-5051: result of triggering the live REST rebuild. {@code alreadyRunning} = the box reported
* a rebuild already in progress (HTTP 409) — the caller can just start polling status. */
public interface RebuildStartCb {
void onStarted(boolean alreadyRunning);
void onErr(String message);
}

/** ADFA-5051: current rebuild state from the box: idle | running | done | error. */
public interface RebuildStatusCb {
void onState(String state);
void onErr(String message);
}

/** ADFA-5051: trigger the in-server blue-green rebuild (POST). Fire-and-forget: the box returns 202
* at once (or 409 if one is already running); the caller then polls {@link #rebuildStatus}. */
public static void rebuildStart(RebuildStartCb cb) {
AppExecutors.get().io().execute(() -> {
int[] status = {0};
try {
httpPost(URL_REBUILD, status);
MAIN.post(() -> cb.onStarted(false));
} catch (Exception e) {
if (status[0] == 409) { MAIN.post(() -> cb.onStarted(true)); return; }
MAIN.post(() -> cb.onErr("could not start rebuild"));
}
});
}

/** ADFA-5051: read the rebuild state file the script writes (idle/running/done/error). */
public static void rebuildStatus(RebuildStatusCb cb) {
AppExecutors.get().io().execute(() -> {
try {
JSONObject o = new JSONObject(httpGet(URL_REBUILD_STATUS));
final String state = o.optString("state", "idle");
MAIN.post(() -> cb.onState(state));
} catch (Exception e) {
MAIN.post(() -> cb.onErr("status unavailable"));
}
});
}

/** Ask the box whether a newer dash-node build is available. Runs off the main thread; the result
* (or an error, when the box is stopped/offline) is posted back to the UI. */
public static void updateCheck(UpdateCb cb) {
Expand Down Expand Up @@ -74,6 +117,25 @@ private static String httpGet(String urlStr) throws Exception {
}
}

/** POST with no body; {@code statusOut[0]} receives the HTTP status so callers can map 409. The
* trigger returns immediately (202), so a short read timeout is fine. */
private static void httpPost(String urlStr, int[] statusOut) throws Exception {
HttpURLConnection c = (HttpURLConnection) new URL(urlStr).openConnection();
try {
c.setUseCaches(false);
c.setConnectTimeout(5000);
c.setReadTimeout(10000);
c.setRequestMethod("POST"); // bodyless trigger; no doOutput/body needed
c.setRequestProperty("Accept", "application/json");
int code = c.getResponseCode();
statusOut[0] = code;
readAll(code >= 200 && code < 400 ? c.getInputStream() : c.getErrorStream());
if (code < 200 || code >= 400) throw new Exception("HTTP " + code);
} finally {
c.disconnect();
}
}

private static String readAll(InputStream is) throws Exception {
if (is == null) return "";
ByteArrayOutputStream buf = new ByteArrayOutputStream();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public class DashboardDetailFragment extends Fragment {
private final Handler main = new Handler(Looper.getMainLooper());
private ViewGroup chips; // FlowLayout in XML — typed as ViewGroup so it wraps chips to 2 lines
private TextView statusChip; // ADFA-5026: live "Up to date / Update available" pill (restyled in place)
private TextView versionChip; // ADFA-5051: "v<version>" chip, updated in place after a live update
private Button rebuild; // de-emphasized when already on the latest
private TextView rebuildHint; // "no rebuild needed" note, shown only when on the latest

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

Expand Down Expand Up @@ -138,19 +139,31 @@ private TextView buildRebuildHint(Button rebuildBtn) {
}

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

/** ADFA-5051: after a successful live (REST) update, refresh the version chip + update pill in
* place so the card reflects the new version immediately (no need to leave and re-open). */
private void refreshAfterLiveUpdate() {
fetchVersionChip();
fetchUpdateStatus();
}

/** Small outlined pill for the meta-chip row (matches ModuleDetailFragment). */
private TextView chip(String text, int colorRes) {
float d = getResources().getDisplayMetrics().density;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,56 @@
* Name : DashboardRebuild.java
* Author : AppDevForAll
* Copyright : Copyright (c) 2026 AppDevForAll
* Description : ADFA-5011. Single entry point for starting a dash-node REST-core rebuild, shared by
* the Module-management hub row and the dashboard detail card so both offer the exact
* same gated flow: busy check (EnvironmentLock) -> internet check -> confirm dialog ->
* start the guarded InstallService rebuild + open the progress screen (which now stays
* put until the rebuild reaches SUCCESS/FAILED).
* Description : ADFA-5011 / ADFA-5051. Single entry point for starting a dash-node REST-core rebuild,
* shared by the Module-management hub row and the dashboard detail card. Gated flow:
* busy check (EnvironmentLock) -> internet check -> confirm dialog -> start.
*
* ADFA-5051 — version-gated cutover: from dash-node 1.2.0 the core updates ITSELF LIVE
* over REST (POST /system/dashboard/rebuild: the blue-green rebuild that stages, smoke-
* tests, atomically swaps and rolls back on failure — no rootfs/proot). Installs still
* on < 1.2.0 predate that, so they take the heavier proot rebuild (InstallService) once
* to reach 1.2.0; from there every later update is the live REST path.
* ============================================================================
*/
package org.iiab.controller.redesign;

import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.view.Gravity;
import android.view.View;
import android.widget.LinearLayout;
import android.widget.TextView;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.Fragment;

import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.android.material.progressindicator.CircularProgressIndicator;

import org.iiab.controller.R;
import org.iiab.controller.install.presentation.InstallService;
import org.iiab.controller.util.AppExecutors;
import org.iiab.controller.util.Snackbars;

public final class DashboardRebuild {
private DashboardRebuild() {}

/** Gate then confirm then start. {@code anchor} is where a "busy"/"no internet" snackbar shows. */
public static void confirmAndStart(@NonNull Fragment host, @NonNull View anchor) {
// Live-rebuild poll cadence + overall cap. The blue-green rebuild does yarn install/build + smoke
// test + a dash-node restart, so allow a generous ceiling; the restart briefly makes the status
// endpoint unreachable, which we treat as "keep polling", not a failure.
private static final long POLL_MS = 2500L;
private static final int MAX_POLLS = 160; // ~6.5 min

/** Gate then confirm then start. {@code anchor} is where a "busy"/"no internet" snackbar shows.
* {@code onLiveUpdated} (nullable) runs after a successful LIVE (REST) update so the caller can
* refresh its version chip / update pill in place (ADFA-5051). */
public static void confirmAndStart(@NonNull Fragment host, @NonNull View anchor,
@Nullable Runnable onLiveUpdated) {
Context ctx = host.requireContext();
if (org.iiab.controller.env.EnvironmentLock.isHeld(ctx)) {
Snackbars.make(anchor, R.string.k2go_install_busy).show();
Expand All @@ -44,13 +66,29 @@ public static void confirmAndStart(@NonNull Fragment host, @NonNull View anchor)
.setTitle(R.string.k2go_dash_rebuild_confirm_title)
.setMessage(R.string.k2go_dash_rebuild_confirm_msg)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.k2go_dash_rebuild, (d, w) -> start(host))
.setPositiveButton(R.string.k2go_dash_rebuild, (d, w) -> start(host, anchor, onLiveUpdated))
.show();
}

/** Kick the foreground rebuild service and open the guarded progress screen (flagged as a rebuild
* so it stays on the animation and blocks leaving until the rebuild finishes). */
private static void start(@NonNull Fragment host) {
/** ADFA-5051: route by the installed dash-node version. >= 1.2.0 updates live over REST; older
* installs take the proot rebuild once as a bridge to 1.2.0. The version read hits disk, so it
* runs off the main thread; the routing itself is posted back to the UI. */
private static void start(@NonNull Fragment host, @NonNull View anchor, @Nullable Runnable onLiveUpdated) {
final Context app = host.requireContext().getApplicationContext();
final Handler main = new Handler(Looper.getMainLooper());
AppExecutors.get().io().execute(() -> {
final boolean rest = DashboardVersion.atLeast(DashboardVersion.installed(app), 1, 2, 0);
main.post(() -> {
if (!host.isAdded()) return;
if (rest) startRest(host, anchor, onLiveUpdated);
else startProot(host);
});
});
}

/** Kick the foreground proot rebuild service and open the guarded progress screen (flagged as a
* rebuild so it stays on the animation and blocks leaving until the rebuild finishes). */
private static void startProot(@NonNull Fragment host) {
Context ctx = host.requireContext();
Intent svc = new Intent(ctx, InstallService.class)
.setAction(InstallService.ACTION_REBUILD_DASHBOARD);
Expand All @@ -60,6 +98,88 @@ private static void start(@NonNull Fragment host) {
.putExtra(SetupProgressActivity.EXTRA_REBUILD, true));
}

/** ADFA-5051: live REST update. Trigger the in-server rebuild, then show a non-cancelable progress
* dialog that polls the state until done/error (tolerating the brief restart window). */
private static void startRest(@NonNull Fragment host, @NonNull View anchor, @Nullable Runnable onLiveUpdated) {
Context ctx = host.requireContext();
LinearLayout box = new LinearLayout(ctx);
box.setOrientation(LinearLayout.HORIZONTAL);
box.setGravity(Gravity.CENTER_VERTICAL);
int pad = Math.round(24 * ctx.getResources().getDisplayMetrics().density);
box.setPadding(pad, pad, pad, pad);
CircularProgressIndicator spin = new CircularProgressIndicator(ctx);
spin.setIndeterminate(true);
box.addView(spin);
TextView msg = new TextView(ctx);
msg.setText(R.string.k2go_dash_live_running);
LinearLayout.LayoutParams mlp = new LinearLayout.LayoutParams(-2, -2);
mlp.leftMargin = pad;
msg.setLayoutParams(mlp);
box.addView(msg);

final AlertDialog dialog = new MaterialAlertDialogBuilder(ctx)
.setTitle(R.string.k2go_dash_live_title)
.setView(box)
.setCancelable(false)
.show();

final Handler poller = new Handler(Looper.getMainLooper());
DashboardClient.rebuildStart(new DashboardClient.RebuildStartCb() {
@Override public void onStarted(boolean alreadyRunning) {
if (!host.isAdded()) { safeDismiss(dialog); return; }
pollStatus(host, dialog, poller, new int[]{0}, onLiveUpdated);
}
@Override public void onErr(String message) {
safeDismiss(dialog);
if (host.isAdded()) Snackbars.make(anchor, R.string.k2go_dash_live_start_failed).show();
}
});
}

/** Poll rebuild state until a terminal one, reusing a single {@code poller} Handler. A status error
* is transient (dash-node restarts mid-swap) so we keep polling until MAX_POLLS. Hitting the cap is
* NOT a failure: the rebuild runs detached server-side and may still finish, so we show a distinct
* "still working in the background" message rather than the rollback/failure one. */
private static void pollStatus(@NonNull Fragment host, @NonNull AlertDialog dialog,
@NonNull Handler poller, int[] tries, @Nullable Runnable onLiveUpdated) {
if (!host.isAdded()) { safeDismiss(dialog); return; }
if (tries[0]++ >= MAX_POLLS) { finishRest(host, dialog, R.string.k2go_dash_live_timeout, onLiveUpdated); return; }
DashboardClient.rebuildStatus(new DashboardClient.RebuildStatusCb() {
@Override public void onState(String state) {
if (!host.isAdded()) { safeDismiss(dialog); return; }
if ("done".equals(state)) { finishRest(host, dialog, R.string.k2go_dash_live_done, onLiveUpdated); return; }
if ("error".equals(state)) { finishRest(host, dialog, R.string.k2go_dash_live_error, onLiveUpdated); return; }
schedule();
}
@Override public void onErr(String message) {
if (!host.isAdded()) { safeDismiss(dialog); return; }
schedule(); // API likely restarting mid-swap; keep waiting
}
private void schedule() {
poller.postDelayed(() -> pollStatus(host, dialog, poller, tries, onLiveUpdated), POLL_MS);
}
});
}

/** Swap the progress dialog for a simple result dialog carrying {@code msgRes} (done/error/timeout).
* On success, fire {@code onLiveUpdated} (ADFA-5051) so the caller refreshes its version/pill in
* place — otherwise the card keeps showing "update available" and users re-tap Rebuild. */
private static void finishRest(@NonNull Fragment host, @NonNull AlertDialog dialog, int msgRes,
@Nullable Runnable onLiveUpdated) {
safeDismiss(dialog);
if (!host.isAdded()) return;
if (msgRes == R.string.k2go_dash_live_done && onLiveUpdated != null) onLiveUpdated.run();
new MaterialAlertDialogBuilder(host.requireContext())
.setTitle(R.string.k2go_dash_live_title)
.setMessage(msgRes)
.setPositiveButton(android.R.string.ok, null)
.show();
}

private static void safeDismiss(@NonNull AlertDialog d) {
try { if (d.isShowing()) d.dismiss(); } catch (Exception ignore) { /* activity gone */ }
}

/** True when the device reports an internet-capable active network. Unknown -> true (let the
* preflight decide), matching the previous inline check in ModuleHubFragment. */
public static boolean hasInternet(@NonNull Context ctx) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,21 @@
public final class DashboardVersion {
private DashboardVersion() {}

/** ADFA-5051: true when {@code version} (x.y.z, any suffix ignored) is >= major.minor.patch.
* A null/unparseable version returns false, so callers default to the safe (proot) path. */
public static boolean atLeast(@Nullable String version, int major, int minor, int patch) {
if (version == null) return false;
String core = version.split("[-+]", 2)[0]; // drop any "-beta"/"+build" suffix
String[] p = core.split("\\.");
int[] want = {major, minor, patch};
for (int i = 0; i < 3; i++) {
int have = 0;
if (i < p.length) { try { have = Integer.parseInt(p[i].trim()); } catch (NumberFormatException e) { have = 0; } }
if (have != want[i]) return have > want[i];
}
return true; // exactly equal
}

/** Installed dash-node version from the rootfs package.json, or null if not found/parseable. */
@Nullable
public static String installed(@NonNull Context ctx) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@ private void addSystemDashboardCard() {

TextView rebuild = statePill(getString(R.string.k2go_dash_rebuild), R.color.k2go_teal);
rebuild.setPadding(px(14), px(6), px(14), px(6));
rebuild.setOnClickListener(v -> DashboardRebuild.confirmAndStart(this, host));
rebuild.setOnClickListener(v -> DashboardRebuild.confirmAndStart(this, host, null));
LinearLayout.LayoutParams tlp = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
tlp.leftMargin = px(10);
Expand Down
Loading
Loading