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
8 changes: 8 additions & 0 deletions controller/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,14 @@
android:exported="false"
android:foregroundServiceType="specialUse" />

<!-- ADFA-5333: runs the LIVE dash-node update in the background (notification only) instead of
behind a blocking modal. Same shape as the download services above: not exported, and
specialUse because it drives the in-server blue-green rebuild rather than a stock transfer. -->
<service
android:name=".redesign.DashboardRebuildService"
android:exported="false"
android:foregroundServiceType="specialUse" />

<!-- ADFA-4954: seeds Kolibri channels through the in-server REST job engine. Same shape as
the ZIM and Books services above: not exported, and specialUse because the work runs
inside the co-located proot rather than being a stock data transfer. -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ public final class EnvironmentLock {
public enum Owner { INSTALL, MODULE, BACKUP, RESTORE, CLONE }

/** ADFA-5146: what is actually holding the environment, for a refusal message that names the
* real cause instead of always saying "an install". */
public enum Holder { CLONE, BACKUP, RESTORE, INSTALL, DOWNLOAD, NONE }
* real cause instead of always saying "an install". ADFA-5333: DASHBOARD = a live dash-node update
* is in flight; it restarts the server, so nothing that touches the server may start on top of it. */
public enum Holder { CLONE, BACKUP, RESTORE, INSTALL, DOWNLOAD, DASHBOARD, NONE }

// Owner marker: line 1 = Owner.name(), line 2 = epoch millis, line 3 = session token.
private static final String MARKER = ".env_lock";
Expand Down Expand Up @@ -180,6 +181,9 @@ public static Holder currentHolder(Context ctx) {
}
}
if (org.iiab.controller.InstallGuard.inProgress(ctx)) return Holder.INSTALL;
// ADFA-5333: a live dashboard update restarts dash-node, so it must block every server-touching op
// (deep-env ops read this via isHeld; live downloads get an explicit check at their start points).
if (org.iiab.controller.redesign.DashboardRebuildService.isRunning()) return Holder.DASHBOARD;
if (isBusyNow()) return Holder.DOWNLOAD; // module installs are caught above by the guard
return Holder.NONE;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,8 @@ private void bankAndReturn(List<Channel> chosen) {
* be left with silence, so the refusal is shown.
*/
private void startLive(List<Channel> chosen) {
// ADFA-5333: a live dashboard update restarts dash-node and would break the seed — defer.
if (org.iiab.controller.redesign.DashboardRebuild.blockedByUpdate(confirm)) return;
final List<Channel> toDownload = missingOnly(chosen);
if (toDownload.isEmpty()) {
refuse(R.string.k2go_kolibri_nothing_to_add);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,9 @@ public static boolean hasPending(Context ctx) {
*/
static boolean canDrainNow(Context ctx) {
if (org.iiab.controller.install.presentation.ModuleQueueRepository.get().isRunning()
|| MapsProvisioner.hasPending(ctx)) {
Log.d(TAG, "kolibri drain blocked: proot (runrole) work is pending/running");
|| MapsProvisioner.hasPending(ctx)
|| org.iiab.controller.redesign.DashboardRebuildService.isRunning()) { // ADFA-5333
Log.d(TAG, "kolibri drain blocked: proot (runrole) or dashboard-update work is in flight");
return false;
}
// ADFA-5074: unfinished work only. This read a merely registered session, so a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c
if (!(getActivity() instanceof SetupLibraryActivity)) return;
SetupLibraryActivity a = (SetupLibraryActivity) getActivity();
if (banks) a.booksWizardConfirm(); // no box yet: bank it
else a.startBooksDownload(); // live: download now
else if (!DashboardRebuild.blockedByUpdate(v)) a.startBooksDownload(); // live: download now (ADFA-5333)
});

return root;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ public static void drain(Context ctx) {
// background processes and concurrent REST work is a recipe for corruption. Defer REST while a
// module-queue (proot) job is pending or running; a later drain pass picks it up once idle.
if (org.iiab.controller.install.presentation.ModuleQueueRepository.get().isRunning()
|| MapsProvisioner.hasPending(ctx)) {
Log.d(TAG, "books drain deferred: proot (runrole) work is pending/running");
|| MapsProvisioner.hasPending(ctx)
|| DashboardRebuildService.isRunning()) { // ADFA-5333: a live dashboard update restarts dash-node
Log.d(TAG, "books drain deferred: proot (runrole) or dashboard-update work is in flight");
return;
}
// ADFA-4954 (ADR-4954 D8): the live REST streams also serialize against each other.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ 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 String URL_REBUILD_CANCEL = BoxEndpoints.API + "/system/dashboard/rebuild/cancel";
private static final Handler MAIN = new Handler(Looper.getMainLooper());

public interface UpdateCb {
Expand All @@ -54,6 +55,14 @@ public interface RebuildStatusCb {
void onErr(String message);
}

/** ADFA-5333: result of asking the box to cancel. {@code cancelled} = the rebuild was stopped and the
* live dashboard left untouched; {@code promoting} = too late to cancel (the swap is finishing).
* Neither true and no error = nothing was running. */
public interface RebuildCancelCb {
void onResult(boolean cancelled, boolean promoting);
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) {
Expand All @@ -69,6 +78,39 @@ public static void rebuildStart(RebuildStartCb cb) {
});
}

/** ADFA-5333: ask the box to cancel the in-flight rebuild. 200 => cancelled; 409 with promoting=true
* => too late (swap finishing); 409 otherwise => nothing running. The body carries the flags, so this
* reads it directly rather than going through the shared bodyless POST. */
public static void rebuildCancel(RebuildCancelCb cb) {
AppExecutors.get().io().execute(() -> {
try {
HttpURLConnection c = (HttpURLConnection) new URL(URL_REBUILD_CANCEL).openConnection();
try {
c.setUseCaches(false);
c.setConnectTimeout(5000);
c.setReadTimeout(10000);
c.setRequestMethod("POST");
c.setRequestProperty("Accept", "application/json");
int code = c.getResponseCode();
String body = readAll(code >= 200 && code < 400 ? c.getInputStream() : c.getErrorStream());
boolean cancelled = code >= 200 && code < 300;
boolean promoting = false;
try {
JSONObject o = new JSONObject(body);
cancelled = o.optBoolean("cancelled", cancelled);
promoting = o.optBoolean("promoting", false);
} catch (Exception ignore) { /* keep status-derived defaults */ }
final boolean fc = cancelled, fp = promoting;
MAIN.post(() -> cb.onResult(fc, fp));
} finally {
c.disconnect();
}
} catch (Exception e) {
MAIN.post(() -> cb.onErr("cancel failed"));
}
});
}

/** ADFA-5051: read the rebuild state file the script writes (idle/running/done/error). */
public static void rebuildStatus(RebuildStatusCb cb) {
AppExecutors.get().io().execute(() -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@
*/
package org.iiab.controller.redesign;

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
Expand All @@ -29,6 +33,8 @@
import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;

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

import org.iiab.controller.R;
import org.iiab.controller.util.AppExecutors;

Expand All @@ -40,6 +46,30 @@ public class DashboardDetailFragment extends Fragment {
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
private View updatingRow; // ADFA-5333: in-progress indicator (indeterminate bar + label + Cancel)
private View updatingCancel; // ADFA-5333: the Cancel affordance beside the bar
private boolean updating; // ADFA-5333: a background rebuild is in flight; don't re-emphasize Rebuild

/** ADFA-5333: the live update runs in the background (DashboardRebuildService), which broadcasts each
* state change. While this card is on screen we show/hide an in-progress bar and, on done, refresh
* the version/pill in place — exactly as the old in-modal completion did. Registered only while
* STARTED, so there is no callback captured across the multi-minute detached rebuild. */
private final BroadcastReceiver rebuildState = new BroadcastReceiver() {
@Override public void onReceive(Context c, Intent i) {
if (!isAdded()) return;
String state = i.getStringExtra(DashboardRebuildService.EXTRA_STATE);
if (DashboardRebuildService.STATE_RUNNING.equals(state)) {
setUpdating(true);
} else if (DashboardRebuildService.STATE_DONE.equals(state)) {
setUpdating(false);
refreshAfterLiveUpdate();
} else if (DashboardRebuildService.STATE_ERROR.equals(state)
|| DashboardRebuildService.STATE_CANCELLED.equals(state)) {
setUpdating(false);
fetchUpdateStatus(); // version unchanged; re-resolve the pill/emphasis
}
}
};

@Nullable
@Override
Expand Down Expand Up @@ -73,16 +103,57 @@ 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, this::refreshAfterLiveUpdate));
rebuild.setOnClickListener(v -> DashboardRebuild.confirmAndStart(this, root));
root.findViewById(R.id.k2go_moddet_install_now).setVisibility(View.GONE);
rebuildHint = buildRebuildHint(rebuild); // ADFA-5026: "no rebuild needed" note (hidden until on-latest)
updatingRow = buildUpdatingRow(rebuild); // ADFA-5333: in-progress bar (hidden until updating)

// ADFA-5026: resolve the live update status (falls back to the last-known cache when offline).
fetchUpdateStatus();

return root;
}

/** ADFA-5333: while visible, listen for the background update's state changes and resolve the current
* state on open — so a card entered mid-update (e.g. from the notification) shows the bar. Explicit-
* package, not-exported — an internal signal from our own service. */
@Override
public void onStart() {
super.onStart();
IntentFilter f = new IntentFilter(DashboardRebuildService.ACTION_STATE);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
requireContext().registerReceiver(rebuildState, f, Context.RECEIVER_NOT_EXPORTED);
} else {
requireContext().registerReceiver(rebuildState, f);
}
resolveInitialUpdatingState();
}

@Override
public void onStop() {
try { requireContext().unregisterReceiver(rebuildState); } catch (IllegalArgumentException ignore) { /* not registered */ }
super.onStop();
}

/** Show the bar if an update is in flight. The live service is the fast answer; if it isn't running
* but the box itself still reports a rebuild (e.g. our process was killed mid-update), re-own it by
* starting the service again — the box replies 409 and the service re-attaches the notification and
* the completion broadcast. */
private void resolveInitialUpdatingState() {
if (DashboardRebuildService.isRunning()) { setUpdating(true); return; }
final Context ctx = requireContext().getApplicationContext();
DashboardClient.rebuildStatus(new DashboardClient.RebuildStatusCb() {
@Override public void onState(String state) {
if (!isAdded()) return;
if (DashboardRebuildService.STATE_RUNNING.equals(state) && !DashboardRebuildService.isRunning()) {
setUpdating(true);
DashboardRebuildService.attach(ctx); // re-own without risking a fresh rebuild
}
}
@Override public void onErr(String message) { /* box stopped/offline — nothing in flight to show */ }
});
}

/** ADFA-5026: ask the box whether a newer build exists and reflect it in the status pill + Rebuild
* emphasis. Shows the cached last-known state right away (if any) so the pill isn't stuck on
* "Checking…", then refreshes from the live check; on failure it keeps the cached state. */
Expand Down Expand Up @@ -111,6 +182,7 @@ private void applyUpdateStatus(boolean updateAvailable) {
else styleChip(statusChip, getString(R.string.k2go_dash_chip_uptodate), R.color.k2go_leaf);
statusChip.setVisibility(View.VISIBLE);
}
if (updating) return; // ADFA-5333: a rebuild is in flight — keep Rebuild disabled and the bar shown
if (rebuild != null) rebuild.setAlpha(updateAvailable ? 1f : 0.6f);
if (rebuildHint != null) rebuildHint.setVisibility(updateAvailable ? View.GONE : View.VISIBLE);
}
Expand Down Expand Up @@ -138,6 +210,93 @@ private TextView buildRebuildHint(Button rebuildBtn) {
return hint;
}

/** ADFA-5333: an in-progress indicator inserted just above Rebuild — a label, then an M3 indeterminate
* bar with a Cancel affordance beside it — shown only while a background update runs (the rebuild
* reports no percentage, so the bar is indeterminate). Built in code so the shared module-detail
* layout is untouched. */
private View buildUpdatingRow(Button rebuildBtn) {
ViewGroup parent = (ViewGroup) rebuildBtn.getParent();
if (parent == null) return null;
float d = getResources().getDisplayMetrics().density;
int side = Math.round(20 * d);

LinearLayout row = new LinearLayout(requireContext());
row.setOrientation(LinearLayout.VERTICAL);
LinearLayout.LayoutParams rlp = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
rlp.leftMargin = side;
rlp.rightMargin = side;
rlp.topMargin = Math.round(8 * d);
row.setLayoutParams(rlp);

TextView label = new TextView(requireContext());
label.setText(R.string.k2go_dash_live_running);
label.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall);
label.setTextColor(ContextCompat.getColor(requireContext(), R.color.k2go_muted));
row.addView(label);

// The bar and Cancel sit on one line: bar takes the width, Cancel is right beside it.
LinearLayout line = new LinearLayout(requireContext());
line.setOrientation(LinearLayout.HORIZONTAL);
line.setGravity(android.view.Gravity.CENTER_VERTICAL);
LinearLayout.LayoutParams llp = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
llp.topMargin = Math.round(4 * d);
line.setLayoutParams(llp);

LinearProgressIndicator bar = new LinearProgressIndicator(requireContext());
bar.setIndeterminate(true);
bar.setIndicatorColor(ContextCompat.getColor(requireContext(), R.color.k2go_teal));
LinearLayout.LayoutParams blp = new LinearLayout.LayoutParams(
0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f); // weight 1 → takes the remaining width
bar.setLayoutParams(blp);
line.addView(bar);

TextView cancel = new TextView(requireContext());
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(requireContext(), R.color.k2go_teal));
int hp = Math.round(12 * d), vp = Math.round(6 * d);
cancel.setPadding(hp, vp, hp, vp);
LinearLayout.LayoutParams clp = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
clp.leftMargin = Math.round(8 * d);
cancel.setLayoutParams(clp);
android.util.TypedValue tv = new android.util.TypedValue();
requireContext().getTheme().resolveAttribute(
android.R.attr.selectableItemBackgroundBorderless, tv, true);
cancel.setBackgroundResource(tv.resourceId);
cancel.setClickable(true);
cancel.setOnClickListener(v -> onCancelUpdate());
line.addView(cancel);
updatingCancel = cancel;

row.addView(line);
row.setVisibility(View.GONE);
parent.addView(row, parent.indexOfChild(rebuildBtn));
return row;
}

/** Ask the service to cancel; the bar stays until STATE_CANCELLED lands (or the update finishes if it
* was too late). Disable the affordance to avoid double taps. */
private void onCancelUpdate() {
if (updatingCancel != null) updatingCancel.setEnabled(false);
Context ctx = requireContext();
ContextCompat.startForegroundService(ctx,
new android.content.Intent(ctx, DashboardRebuildService.class).setAction(DashboardRebuildService.ACTION_CANCEL));
}

/** Toggle the in-progress state: show/hide the bar and disable Rebuild so it can't be re-triggered
* mid-update. On exit, the caller re-resolves the pill/emphasis via {@link #fetchUpdateStatus}. */
private void setUpdating(boolean on) {
updating = on;
if (updatingRow != null) updatingRow.setVisibility(on ? View.VISIBLE : View.GONE);
if (updatingCancel != null) updatingCancel.setEnabled(on); // re-enable when a new update shows
if (rebuild != null) { rebuild.setEnabled(!on); rebuild.setAlpha(on ? 0.5f : 1f); }
if (on && rebuildHint != null) rebuildHint.setVisibility(View.GONE);
}

/** Read the installed version from the rootfs package.json on disk (authoritative, always present;
* 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. */
Expand Down
Loading