Skip to content

Commit 859e7bc

Browse files
Merge pull request #493 from appdevforall/feat/ADFA-5333-dashboard-update-background
ADFA-5333 feat(dashboard): run the live update in the background
2 parents fba8ee2 + 276af69 commit 859e7bc

53 files changed

Lines changed: 862 additions & 185 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/AndroidManifest.xml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,14 @@
154154
android:exported="false"
155155
android:foregroundServiceType="specialUse" />
156156

157+
<!-- ADFA-5333: runs the LIVE dash-node update in the background (notification only) instead of
158+
behind a blocking modal. Same shape as the download services above: not exported, and
159+
specialUse because it drives the in-server blue-green rebuild rather than a stock transfer. -->
160+
<service
161+
android:name=".redesign.DashboardRebuildService"
162+
android:exported="false"
163+
android:foregroundServiceType="specialUse" />
164+
157165
<!-- ADFA-4954: seeds Kolibri channels through the in-server REST job engine. Same shape as
158166
the ZIM and Books services above: not exported, and specialUse because the work runs
159167
inside the co-located proot rather than being a stock data transfer. -->

‎controller/app/src/main/java/org/iiab/controller/env/EnvironmentLock.java‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,9 @@ public final class EnvironmentLock {
5050
public enum Owner { INSTALL, MODULE, BACKUP, RESTORE, CLONE }
5151

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

5657
// Owner marker: line 1 = Owner.name(), line 2 = epoch millis, line 3 = session token.
5758
private static final String MARKER = ".env_lock";
@@ -180,6 +181,9 @@ public static Holder currentHolder(Context ctx) {
180181
}
181182
}
182183
if (org.iiab.controller.InstallGuard.inProgress(ctx)) return Holder.INSTALL;
184+
// ADFA-5333: a live dashboard update restarts dash-node, so it must block every server-touching op
185+
// (deep-env ops read this via isHeld; live downloads get an explicit check at their start points).
186+
if (org.iiab.controller.redesign.DashboardRebuildService.isRunning()) return Holder.DASHBOARD;
183187
if (isBusyNow()) return Holder.DOWNLOAD; // module installs are caught above by the guard
184188
return Holder.NONE;
185189
}

‎controller/app/src/main/java/org/iiab/controller/kolibri/presentation/KolibriConfirmFragment.java‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,8 @@ private void bankAndReturn(List<Channel> chosen) {
376376
* be left with silence, so the refusal is shown.
377377
*/
378378
private void startLive(List<Channel> chosen) {
379+
// ADFA-5333: a live dashboard update restarts dash-node and would break the seed — defer.
380+
if (org.iiab.controller.redesign.DashboardRebuild.blockedByUpdate(confirm)) return;
379381
final List<Channel> toDownload = missingOnly(chosen);
380382
if (toDownload.isEmpty()) {
381383
refuse(R.string.k2go_kolibri_nothing_to_add);

‎controller/app/src/main/java/org/iiab/controller/kolibri/presentation/KolibriProvisioner.java‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,9 @@ public static boolean hasPending(Context ctx) {
5959
*/
6060
static boolean canDrainNow(Context ctx) {
6161
if (org.iiab.controller.install.presentation.ModuleQueueRepository.get().isRunning()
62-
|| MapsProvisioner.hasPending(ctx)) {
63-
Log.d(TAG, "kolibri drain blocked: proot (runrole) work is pending/running");
62+
|| MapsProvisioner.hasPending(ctx)
63+
|| org.iiab.controller.redesign.DashboardRebuildService.isRunning()) { // ADFA-5333
64+
Log.d(TAG, "kolibri drain blocked: proot (runrole) or dashboard-update work is in flight");
6465
return false;
6566
}
6667
// ADFA-5074: unfinished work only. This read a merely registered session, so a

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c
7979
if (!(getActivity() instanceof SetupLibraryActivity)) return;
8080
SetupLibraryActivity a = (SetupLibraryActivity) getActivity();
8181
if (banks) a.booksWizardConfirm(); // no box yet: bank it
82-
else a.startBooksDownload(); // live: download now
82+
else if (!DashboardRebuild.blockedByUpdate(v)) a.startBooksDownload(); // live: download now (ADFA-5333)
8383
});
8484

8585
return root;

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,9 @@ public static void drain(Context ctx) {
4141
// background processes and concurrent REST work is a recipe for corruption. Defer REST while a
4242
// module-queue (proot) job is pending or running; a later drain pass picks it up once idle.
4343
if (org.iiab.controller.install.presentation.ModuleQueueRepository.get().isRunning()
44-
|| MapsProvisioner.hasPending(ctx)) {
45-
Log.d(TAG, "books drain deferred: proot (runrole) work is pending/running");
44+
|| MapsProvisioner.hasPending(ctx)
45+
|| DashboardRebuildService.isRunning()) { // ADFA-5333: a live dashboard update restarts dash-node
46+
Log.d(TAG, "books drain deferred: proot (runrole) or dashboard-update work is in flight");
4647
return;
4748
}
4849
// ADFA-4954 (ADR-4954 D8): the live REST streams also serialize against each other.

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ private DashboardClient() {}
3434
private static final String URL_UPDATE_CHECK = BoxEndpoints.API + "/system/dashboard/update-check";
3535
private static final String URL_REBUILD = BoxEndpoints.API + "/system/dashboard/rebuild";
3636
private static final String URL_REBUILD_STATUS = BoxEndpoints.API + "/system/dashboard/rebuild/status";
37+
private static final String URL_REBUILD_CANCEL = BoxEndpoints.API + "/system/dashboard/rebuild/cancel";
3738
private static final Handler MAIN = new Handler(Looper.getMainLooper());
3839

3940
public interface UpdateCb {
@@ -54,6 +55,14 @@ public interface RebuildStatusCb {
5455
void onErr(String message);
5556
}
5657

58+
/** ADFA-5333: result of asking the box to cancel. {@code cancelled} = the rebuild was stopped and the
59+
* live dashboard left untouched; {@code promoting} = too late to cancel (the swap is finishing).
60+
* Neither true and no error = nothing was running. */
61+
public interface RebuildCancelCb {
62+
void onResult(boolean cancelled, boolean promoting);
63+
void onErr(String message);
64+
}
65+
5766
/** ADFA-5051: trigger the in-server blue-green rebuild (POST). Fire-and-forget: the box returns 202
5867
* at once (or 409 if one is already running); the caller then polls {@link #rebuildStatus}. */
5968
public static void rebuildStart(RebuildStartCb cb) {
@@ -69,6 +78,39 @@ public static void rebuildStart(RebuildStartCb cb) {
6978
});
7079
}
7180

81+
/** ADFA-5333: ask the box to cancel the in-flight rebuild. 200 => cancelled; 409 with promoting=true
82+
* => too late (swap finishing); 409 otherwise => nothing running. The body carries the flags, so this
83+
* reads it directly rather than going through the shared bodyless POST. */
84+
public static void rebuildCancel(RebuildCancelCb cb) {
85+
AppExecutors.get().io().execute(() -> {
86+
try {
87+
HttpURLConnection c = (HttpURLConnection) new URL(URL_REBUILD_CANCEL).openConnection();
88+
try {
89+
c.setUseCaches(false);
90+
c.setConnectTimeout(5000);
91+
c.setReadTimeout(10000);
92+
c.setRequestMethod("POST");
93+
c.setRequestProperty("Accept", "application/json");
94+
int code = c.getResponseCode();
95+
String body = readAll(code >= 200 && code < 400 ? c.getInputStream() : c.getErrorStream());
96+
boolean cancelled = code >= 200 && code < 300;
97+
boolean promoting = false;
98+
try {
99+
JSONObject o = new JSONObject(body);
100+
cancelled = o.optBoolean("cancelled", cancelled);
101+
promoting = o.optBoolean("promoting", false);
102+
} catch (Exception ignore) { /* keep status-derived defaults */ }
103+
final boolean fc = cancelled, fp = promoting;
104+
MAIN.post(() -> cb.onResult(fc, fp));
105+
} finally {
106+
c.disconnect();
107+
}
108+
} catch (Exception e) {
109+
MAIN.post(() -> cb.onErr("cancel failed"));
110+
}
111+
});
112+
}
113+
72114
/** ADFA-5051: read the rebuild state file the script writes (idle/running/done/error). */
73115
public static void rebuildStatus(RebuildStatusCb cb) {
74116
AppExecutors.get().io().execute(() -> {

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

Lines changed: 160 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
*/
1313
package org.iiab.controller.redesign;
1414

15+
import android.content.BroadcastReceiver;
1516
import android.content.Context;
17+
import android.content.Intent;
18+
import android.content.IntentFilter;
19+
import android.os.Build;
1620
import android.os.Bundle;
1721
import android.os.Handler;
1822
import android.os.Looper;
@@ -29,6 +33,8 @@
2933
import androidx.core.content.ContextCompat;
3034
import androidx.fragment.app.Fragment;
3135

36+
import com.google.android.material.progressindicator.LinearProgressIndicator;
37+
3238
import org.iiab.controller.R;
3339
import org.iiab.controller.util.AppExecutors;
3440

@@ -40,6 +46,30 @@ public class DashboardDetailFragment extends Fragment {
4046
private TextView versionChip; // ADFA-5051: "v<version>" chip, updated in place after a live update
4147
private Button rebuild; // de-emphasized when already on the latest
4248
private TextView rebuildHint; // "no rebuild needed" note, shown only when on the latest
49+
private View updatingRow; // ADFA-5333: in-progress indicator (indeterminate bar + label + Cancel)
50+
private View updatingCancel; // ADFA-5333: the Cancel affordance beside the bar
51+
private boolean updating; // ADFA-5333: a background rebuild is in flight; don't re-emphasize Rebuild
52+
53+
/** ADFA-5333: the live update runs in the background (DashboardRebuildService), which broadcasts each
54+
* state change. While this card is on screen we show/hide an in-progress bar and, on done, refresh
55+
* the version/pill in place — exactly as the old in-modal completion did. Registered only while
56+
* STARTED, so there is no callback captured across the multi-minute detached rebuild. */
57+
private final BroadcastReceiver rebuildState = new BroadcastReceiver() {
58+
@Override public void onReceive(Context c, Intent i) {
59+
if (!isAdded()) return;
60+
String state = i.getStringExtra(DashboardRebuildService.EXTRA_STATE);
61+
if (DashboardRebuildService.STATE_RUNNING.equals(state)) {
62+
setUpdating(true);
63+
} else if (DashboardRebuildService.STATE_DONE.equals(state)) {
64+
setUpdating(false);
65+
refreshAfterLiveUpdate();
66+
} else if (DashboardRebuildService.STATE_ERROR.equals(state)
67+
|| DashboardRebuildService.STATE_CANCELLED.equals(state)) {
68+
setUpdating(false);
69+
fetchUpdateStatus(); // version unchanged; re-resolve the pill/emphasis
70+
}
71+
}
72+
};
4373

4474
@Nullable
4575
@Override
@@ -73,16 +103,57 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c
73103
// "Rebuild"; hide the secondary "Install now".
74104
rebuild = root.findViewById(R.id.k2go_moddet_schedule);
75105
rebuild.setText(R.string.k2go_dash_rebuild);
76-
rebuild.setOnClickListener(v -> DashboardRebuild.confirmAndStart(this, root, this::refreshAfterLiveUpdate));
106+
rebuild.setOnClickListener(v -> DashboardRebuild.confirmAndStart(this, root));
77107
root.findViewById(R.id.k2go_moddet_install_now).setVisibility(View.GONE);
78108
rebuildHint = buildRebuildHint(rebuild); // ADFA-5026: "no rebuild needed" note (hidden until on-latest)
109+
updatingRow = buildUpdatingRow(rebuild); // ADFA-5333: in-progress bar (hidden until updating)
79110

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

83114
return root;
84115
}
85116

117+
/** ADFA-5333: while visible, listen for the background update's state changes and resolve the current
118+
* state on open — so a card entered mid-update (e.g. from the notification) shows the bar. Explicit-
119+
* package, not-exported — an internal signal from our own service. */
120+
@Override
121+
public void onStart() {
122+
super.onStart();
123+
IntentFilter f = new IntentFilter(DashboardRebuildService.ACTION_STATE);
124+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
125+
requireContext().registerReceiver(rebuildState, f, Context.RECEIVER_NOT_EXPORTED);
126+
} else {
127+
requireContext().registerReceiver(rebuildState, f);
128+
}
129+
resolveInitialUpdatingState();
130+
}
131+
132+
@Override
133+
public void onStop() {
134+
try { requireContext().unregisterReceiver(rebuildState); } catch (IllegalArgumentException ignore) { /* not registered */ }
135+
super.onStop();
136+
}
137+
138+
/** Show the bar if an update is in flight. The live service is the fast answer; if it isn't running
139+
* but the box itself still reports a rebuild (e.g. our process was killed mid-update), re-own it by
140+
* starting the service again — the box replies 409 and the service re-attaches the notification and
141+
* the completion broadcast. */
142+
private void resolveInitialUpdatingState() {
143+
if (DashboardRebuildService.isRunning()) { setUpdating(true); return; }
144+
final Context ctx = requireContext().getApplicationContext();
145+
DashboardClient.rebuildStatus(new DashboardClient.RebuildStatusCb() {
146+
@Override public void onState(String state) {
147+
if (!isAdded()) return;
148+
if (DashboardRebuildService.STATE_RUNNING.equals(state) && !DashboardRebuildService.isRunning()) {
149+
setUpdating(true);
150+
DashboardRebuildService.attach(ctx); // re-own without risking a fresh rebuild
151+
}
152+
}
153+
@Override public void onErr(String message) { /* box stopped/offline — nothing in flight to show */ }
154+
});
155+
}
156+
86157
/** ADFA-5026: ask the box whether a newer build exists and reflect it in the status pill + Rebuild
87158
* emphasis. Shows the cached last-known state right away (if any) so the pill isn't stuck on
88159
* "Checking…", then refreshes from the live check; on failure it keeps the cached state. */
@@ -111,6 +182,7 @@ private void applyUpdateStatus(boolean updateAvailable) {
111182
else styleChip(statusChip, getString(R.string.k2go_dash_chip_uptodate), R.color.k2go_leaf);
112183
statusChip.setVisibility(View.VISIBLE);
113184
}
185+
if (updating) return; // ADFA-5333: a rebuild is in flight — keep Rebuild disabled and the bar shown
114186
if (rebuild != null) rebuild.setAlpha(updateAvailable ? 1f : 0.6f);
115187
if (rebuildHint != null) rebuildHint.setVisibility(updateAvailable ? View.GONE : View.VISIBLE);
116188
}
@@ -138,6 +210,93 @@ private TextView buildRebuildHint(Button rebuildBtn) {
138210
return hint;
139211
}
140212

213+
/** ADFA-5333: an in-progress indicator inserted just above Rebuild — a label, then an M3 indeterminate
214+
* bar with a Cancel affordance beside it — shown only while a background update runs (the rebuild
215+
* reports no percentage, so the bar is indeterminate). Built in code so the shared module-detail
216+
* layout is untouched. */
217+
private View buildUpdatingRow(Button rebuildBtn) {
218+
ViewGroup parent = (ViewGroup) rebuildBtn.getParent();
219+
if (parent == null) return null;
220+
float d = getResources().getDisplayMetrics().density;
221+
int side = Math.round(20 * d);
222+
223+
LinearLayout row = new LinearLayout(requireContext());
224+
row.setOrientation(LinearLayout.VERTICAL);
225+
LinearLayout.LayoutParams rlp = new LinearLayout.LayoutParams(
226+
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
227+
rlp.leftMargin = side;
228+
rlp.rightMargin = side;
229+
rlp.topMargin = Math.round(8 * d);
230+
row.setLayoutParams(rlp);
231+
232+
TextView label = new TextView(requireContext());
233+
label.setText(R.string.k2go_dash_live_running);
234+
label.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall);
235+
label.setTextColor(ContextCompat.getColor(requireContext(), R.color.k2go_muted));
236+
row.addView(label);
237+
238+
// The bar and Cancel sit on one line: bar takes the width, Cancel is right beside it.
239+
LinearLayout line = new LinearLayout(requireContext());
240+
line.setOrientation(LinearLayout.HORIZONTAL);
241+
line.setGravity(android.view.Gravity.CENTER_VERTICAL);
242+
LinearLayout.LayoutParams llp = new LinearLayout.LayoutParams(
243+
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
244+
llp.topMargin = Math.round(4 * d);
245+
line.setLayoutParams(llp);
246+
247+
LinearProgressIndicator bar = new LinearProgressIndicator(requireContext());
248+
bar.setIndeterminate(true);
249+
bar.setIndicatorColor(ContextCompat.getColor(requireContext(), R.color.k2go_teal));
250+
LinearLayout.LayoutParams blp = new LinearLayout.LayoutParams(
251+
0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f); // weight 1 → takes the remaining width
252+
bar.setLayoutParams(blp);
253+
line.addView(bar);
254+
255+
TextView cancel = new TextView(requireContext());
256+
cancel.setText(R.string.k2go_dash_cancel);
257+
cancel.setAllCaps(true);
258+
cancel.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelLarge);
259+
cancel.setTextColor(ContextCompat.getColor(requireContext(), R.color.k2go_teal));
260+
int hp = Math.round(12 * d), vp = Math.round(6 * d);
261+
cancel.setPadding(hp, vp, hp, vp);
262+
LinearLayout.LayoutParams clp = new LinearLayout.LayoutParams(
263+
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
264+
clp.leftMargin = Math.round(8 * d);
265+
cancel.setLayoutParams(clp);
266+
android.util.TypedValue tv = new android.util.TypedValue();
267+
requireContext().getTheme().resolveAttribute(
268+
android.R.attr.selectableItemBackgroundBorderless, tv, true);
269+
cancel.setBackgroundResource(tv.resourceId);
270+
cancel.setClickable(true);
271+
cancel.setOnClickListener(v -> onCancelUpdate());
272+
line.addView(cancel);
273+
updatingCancel = cancel;
274+
275+
row.addView(line);
276+
row.setVisibility(View.GONE);
277+
parent.addView(row, parent.indexOfChild(rebuildBtn));
278+
return row;
279+
}
280+
281+
/** Ask the service to cancel; the bar stays until STATE_CANCELLED lands (or the update finishes if it
282+
* was too late). Disable the affordance to avoid double taps. */
283+
private void onCancelUpdate() {
284+
if (updatingCancel != null) updatingCancel.setEnabled(false);
285+
Context ctx = requireContext();
286+
ContextCompat.startForegroundService(ctx,
287+
new android.content.Intent(ctx, DashboardRebuildService.class).setAction(DashboardRebuildService.ACTION_CANCEL));
288+
}
289+
290+
/** Toggle the in-progress state: show/hide the bar and disable Rebuild so it can't be re-triggered
291+
* mid-update. On exit, the caller re-resolves the pill/emphasis via {@link #fetchUpdateStatus}. */
292+
private void setUpdating(boolean on) {
293+
updating = on;
294+
if (updatingRow != null) updatingRow.setVisibility(on ? View.VISIBLE : View.GONE);
295+
if (updatingCancel != null) updatingCancel.setEnabled(on); // re-enable when a new update shows
296+
if (rebuild != null) { rebuild.setEnabled(!on); rebuild.setAlpha(on ? 0.5f : 1f); }
297+
if (on && rebuildHint != null) rebuildHint.setVisibility(View.GONE);
298+
}
299+
141300
/** Read the installed version from the rootfs package.json on disk (authoritative, always present;
142301
* no network/proot) and show a "v<version>" chip. ADFA-5051: reuses the same chip on refresh so a
143302
* live update updates it in place instead of prepending a duplicate. */

0 commit comments

Comments
 (0)