1212 */
1313package org .iiab .controller .redesign ;
1414
15+ import android .content .BroadcastReceiver ;
1516import android .content .Context ;
17+ import android .content .Intent ;
18+ import android .content .IntentFilter ;
19+ import android .os .Build ;
1620import android .os .Bundle ;
1721import android .os .Handler ;
1822import android .os .Looper ;
2933import androidx .core .content .ContextCompat ;
3034import androidx .fragment .app .Fragment ;
3135
36+ import com .google .android .material .progressindicator .LinearProgressIndicator ;
37+
3238import org .iiab .controller .R ;
3339import 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