From bd6c28c6410f9d472f7c6b50a166044b0143a544 Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Thu, 27 Aug 2026 08:06:23 -0600 Subject: [PATCH 1/8] ADFA-4898: surface a failed proot module install and let the user retry it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A module runrole failure was already detected (added to failedModules) but folded into a success-shaped DONE — no surface, no retry — so a failed install looked like a silent success and could not be re-run. - ModuleQueueState: hasFailures() / didFail(module) expose the failure on the DONE terminal (no new phase, so every existing "queue finished" consumer is unchanged; only the surfaces that care read it). - Module management (ModuleHubFragment): a module that failed in the last batch shows a "Couldn't install" pill and a Retry button (no schedule checkbox in that state); refreshes live via the module-queue observer. - Notification: a batch that ends with failures leaves a dismissible "install failed" notification (distinct id from the foreground one) that opens Module management. - InstallService.retryModules(ctx, modules): re-fires ACTION_START_MODULES for the failed module(s) — user action only, no auto-retry; for maps it re-attaches this session's retained layer selection (EXTRA_MAPS_*). runrole --reinstall re-converges when the module is healthy upstream. Reuses existing strings (k2go_mod_phase_failed, k2go_home_retry) — no new translations. Off-device / non-module flows unchanged. Follow-ups (same ticket, separate slices — each needs its own device round): movement- based stall detection, cancel with confirmation + immediate retry, and a "failed" state on the finishing-setup screen. --- .../install/presentation/InstallService.java | 56 ++++++++++++++++++- .../presentation/ModuleQueueState.java | 15 +++++ .../redesign/ModuleHubFragment.java | 36 ++++++++++-- 3 files changed, 100 insertions(+), 7 deletions(-) diff --git a/controller/app/src/main/java/org/iiab/controller/install/presentation/InstallService.java b/controller/app/src/main/java/org/iiab/controller/install/presentation/InstallService.java index 5d74d73fe..8197d3168 100644 --- a/controller/app/src/main/java/org/iiab/controller/install/presentation/InstallService.java +++ b/controller/app/src/main/java/org/iiab/controller/install/presentation/InstallService.java @@ -206,6 +206,10 @@ public final class InstallService extends Service { // ADFA-4900: wizard maps per-layer config (only set when the queue is {"maps"} from the wizard). private boolean hasMapsConfig; private String mapsVector, mapsSat, mapsTerrain; + // ADFA-4898: last maps layer selection, retained process-scoped so a user-confirmed Retry of a + // failed maps install re-fires with the same layers (same session; not persisted to disk). + private static String sRetryMapsVector, sRetryMapsSat, sRetryMapsTerrain; + private static boolean sRetryMapsSearch; private boolean mapsSearchOn; private File iiabRootDir; // filesDir/rootfs @@ -348,6 +352,10 @@ public int onStartCommand(Intent intent, int flags, int startId) { mapsTerrain = intent.getStringExtra(EXTRA_MAPS_TERRAIN); mapsSearchOn = intent.getBooleanExtra(EXTRA_MAPS_SEARCH, false); hasMapsConfig = mapsVector != null && moduleQueue.contains("maps"); + if (hasMapsConfig) { // ADFA-4898: retain for a possible user-confirmed Retry of maps + sRetryMapsVector = mapsVector; sRetryMapsSat = mapsSat; + sRetryMapsTerrain = mapsTerrain; sRetryMapsSearch = mapsSearchOn; + } startForeground(NOTIFICATION_ID, buildNotification(getString(R.string.install_busy_modules))); acquireHardwareLocks(); @@ -1054,6 +1062,24 @@ private String mapsInstallCmd() { mapsVector, mapsSat, mapsTerrain, mapsSearchOn); } + /** + * ADFA-4898: re-run the given proot module(s) after a failed batch — the user-confirmed Retry + * (same ACTION_START_MODULES intent the provisioners fire). For maps it re-attaches this + * session's retained layer selection. No auto-retry: only a user action calls this. + */ + public static void retryModules(Context ctx, java.util.List modules) { + if (ctx == null || modules == null || modules.isEmpty()) return; + Intent i = new Intent(ctx, InstallService.class).setAction(ACTION_START_MODULES); + i.putExtra(EXTRA_MODULES, modules.toArray(new String[0])); + if (modules.contains("maps") && sRetryMapsVector != null) { + i.putExtra(EXTRA_MAPS_VECTOR, sRetryMapsVector); + i.putExtra(EXTRA_MAPS_SAT, sRetryMapsSat); + i.putExtra(EXTRA_MAPS_TERRAIN, sRetryMapsTerrain); + i.putExtra(EXTRA_MAPS_SEARCH, sRetryMapsSearch); + } + androidx.core.content.ContextCompat.startForegroundService(ctx, i); + } + private void finishModuleQueue() { if (finished) return; finished = true; @@ -1062,10 +1088,38 @@ private void finishModuleQueue() { // observer that restarts the server (canStartServer() requires !InstallGuard.inProgress) is not // raced by teardown()'s later clear. teardown() clears it again (idempotent). org.iiab.controller.InstallGuard.end(this); - ModuleQueueRepository.get().postDone(new java.util.ArrayList<>(failedModules)); + java.util.List failed = new java.util.ArrayList<>(failedModules); + ModuleQueueRepository.get().postDone(failed); + // ADFA-4898: a module batch that finished with failures leaves a dismissible notification, so a + // user who backgrounded the install learns it did not succeed and can reopen to Retry. + if (!failed.isEmpty()) postModuleFailureNotification(failed); teardown(); } + /** + * ADFA-4898: dismissible "install failed" notification for a module batch that ended with failures. + * Distinct id from the foreground one (removed by teardown's stopForeground); tapping opens Module + * management, where the failed module shows a Retry. + */ + private void postModuleFailureNotification(java.util.List failed) { + NotificationManager m = getSystemService(NotificationManager.class); + if (m == null) return; + Intent open = new Intent(this, org.iiab.controller.redesign.SetupLibraryActivity.class) + .putExtra(org.iiab.controller.redesign.SetupLibraryActivity.EXTRA_MODULE_MGMT, true) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + android.app.PendingIntent pi = android.app.PendingIntent.getActivity(this, 0, open, + android.app.PendingIntent.FLAG_IMMUTABLE | android.app.PendingIntent.FLAG_UPDATE_CURRENT); + Notification n = new NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.k2go_mod_phase_failed)) + .setContentText(android.text.TextUtils.join(", ", failed)) + .setSmallIcon(android.R.drawable.stat_notify_error) + .setContentIntent(pi) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .build(); + m.notify(NOTIFICATION_ID + 4, n); + } + private void persistQueue() { getSharedPreferences("iiab_queue_prefs", Context.MODE_PRIVATE).edit() .putString("pending_modules", android.text.TextUtils.join(",", new java.util.ArrayList<>(moduleQueue))) diff --git a/controller/app/src/main/java/org/iiab/controller/install/presentation/ModuleQueueState.java b/controller/app/src/main/java/org/iiab/controller/install/presentation/ModuleQueueState.java index 8c22a5c7e..de079d9d0 100644 --- a/controller/app/src/main/java/org/iiab/controller/install/presentation/ModuleQueueState.java +++ b/controller/app/src/main/java/org/iiab/controller/install/presentation/ModuleQueueState.java @@ -68,6 +68,21 @@ public boolean isInstalling(String moduleKey) { return phase == Phase.RUNNING && currentModule != null && currentModule.equals(moduleKey); } + /** + * ADFA-4898: the queue finished with at least one module's runrole failed. An explicit signal so a + * failed batch is not read as a clean success — kept on the DONE terminal (the failedModules list is + * already published there) rather than a separate phase, so every existing "queue finished" consumer + * keeps working and only the surfaces that care read this. + */ + public boolean hasFailures() { + return phase == Phase.DONE && !failedModules.isEmpty(); + } + + /** True when {@code moduleKey} failed in the batch that just finished. */ + public boolean didFail(String moduleKey) { + return phase == Phase.DONE && failedModules.contains(moduleKey); + } + public static ModuleQueueState idle() { return new ModuleQueueState(Phase.IDLE, null, 0, INDETERMINATE, ETA_UNKNOWN, null, 0L); } diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/ModuleHubFragment.java b/controller/app/src/main/java/org/iiab/controller/redesign/ModuleHubFragment.java index 20a0231e6..8b4b5d4f2 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/ModuleHubFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/ModuleHubFragment.java @@ -414,6 +414,9 @@ private void addHiddenSection() { * badge when banked, else a chevron. */ private View cardRow(final ModuleCards.Card c, final boolean isInstalled, final boolean unknown) { + // ADFA-4898: did this module's runrole fail in the last finished batch? (DONE + in failedModules) + final boolean failed = !isInstalled + && org.iiab.controller.install.presentation.ModuleQueueRepository.get().current().didFail(c.key()); LinearLayout row = new LinearLayout(requireContext()); row.setOrientation(LinearLayout.HORIZONTAL); row.setGravity(Gravity.CENTER_VERTICAL); @@ -435,7 +438,7 @@ private View cardRow(final ModuleCards.Card c, final boolean isInstalled, // no checkbox. The row still opens its detail, which is where "what is this" lives. // ADFA-5104: and no tick when the flags could not be read either. Ticking would bank an // order we have no grounds to take. - if (!isInstalled && !unknown && !c.hasSelector) { // ADFA-4958: tick to schedule several at once (maps uses its own selector) + if (!isInstalled && !unknown && !failed && !c.hasSelector) { // ADFA-4958: tick to schedule several at once (maps uses its own selector). ADFA-4898: a failed module shows Retry, not the checkbox. com.google.android.material.checkbox.MaterialCheckBox cb = new com.google.android.material.checkbox.MaterialCheckBox(requireContext()); cb.setChecked(ModuleWishlist.contains(requireContext(), c.key())); @@ -485,16 +488,37 @@ private View cardRow(final ModuleCards.Card c, final boolean isInstalled, boolean scheduled = !isInstalled && !unknown && ModuleWishlist.contains(requireContext(), c.key()); TextView pill = statePill( isInstalled ? getString(R.string.k2go_mod_phase_done) - : unknown ? getString(R.string.k2go_state_no_answer) - : scheduled ? getString(R.string.k2go_mod_scheduled) - : getString(R.string.k2go_state_not_installed), + : failed ? getString(R.string.k2go_mod_phase_failed) + : unknown ? getString(R.string.k2go_state_no_answer) + : scheduled ? getString(R.string.k2go_mod_scheduled) + : getString(R.string.k2go_state_not_installed), isInstalled ? R.color.k2go_leaf - : unknown ? R.color.k2go_amber_text - : scheduled ? R.color.k2go_teal : R.color.k2go_muted); + : failed ? R.color.k2go_clay + : unknown ? R.color.k2go_amber_text + : scheduled ? R.color.k2go_teal : R.color.k2go_muted); LinearLayout.LayoutParams tlp = new LinearLayout.LayoutParams( LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); tlp.leftMargin = px(10); row.addView(pill, tlp); + // ADFA-4898: a failed module offers a user-confirmed Retry right on the row (no auto-retry). + if (failed) { + com.google.android.material.button.MaterialButton retry = + new com.google.android.material.button.MaterialButton(requireContext(), null, + com.google.android.material.R.attr.materialButtonOutlinedStyle); + retry.setText(R.string.k2go_home_retry); + retry.setOnClickListener(v -> { + if (org.iiab.controller.env.EnvironmentLock.isHeld(requireContext())) { + Snackbars.make(v, org.iiab.controller.util.BusyMessage.resFor(requireContext())).show(); + return; + } + org.iiab.controller.install.presentation.InstallService.retryModules( + requireContext(), java.util.Collections.singletonList(c.key())); + }); + LinearLayout.LayoutParams rblp = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); + rblp.leftMargin = px(8); + row.addView(retry, rblp); + } return row; } From 478cdd3ecc71b1241463634afa24ccdc1065c1ee Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Thu, 27 Aug 2026 14:59:45 -0600 Subject: [PATCH 2/8] ADFA-4898: move module retry into the detail; keep the finishing header amber on a failed module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retry lived on the hub row and the finishing screen flipped to a green "Adding your content" when a module runrole had failed — reading a failed batch as success. Move recovery to where the user looks for it, keep the failure honest, no new copy. - SetupProgressActivity: a module batch that reaches its terminal with a failed runrole (server already up) keeps the same amber "installing" header instead of the green "Adding your content"; the failure and Retry surface per-module below, not in this batch-level header. - ModuleDetailFragment: a failed module (per-module didFail(key); installed outranks it) shows a persistent "Couldn't install" chip and turns the two actions into Retry (re-fires just this module, user-confirmed, busy-gated, then bounces to the live hub) + Back — the slot-reuse this switch already does for Recover. - ModuleHubFragment: drop the per-row Retry button; the "Couldn't install" pill is the entry into the detail, so a batch where one of many modules failed never sprays Retry across the list. - ModuleInstallFragment: route the progress line's failed check through the shared ModuleQueueState.didFail(key) instead of a hand-rolled failedModules.contains, so every surface shares one predicate. --- .../redesign/ModuleDetailFragment.java | 27 +++++++++++++++++++ .../redesign/ModuleHubFragment.java | 23 +++------------- .../redesign/ModuleInstallFragment.java | 5 +++- .../redesign/SetupProgressActivity.java | 13 ++++++--- 4 files changed, 45 insertions(+), 23 deletions(-) diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/ModuleDetailFragment.java b/controller/app/src/main/java/org/iiab/controller/redesign/ModuleDetailFragment.java index e59e1c7f7..f0c2149ab 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/ModuleDetailFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/ModuleDetailFragment.java @@ -143,6 +143,33 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c chipRow.addView(chip(getString(R.string.k2go_mod_phase_done), R.color.k2go_leaf)); return; // nothing to offer: a module cannot be uninstalled or reinstalled here } + // ADFA-4898: this module's runrole failed in the last finished batch — the SAME per-module + // didFail(key) that colours the hub's "Couldn't install" pill, so only the module that + // actually failed lands here (a batch where one of many failed does not turn the rest into + // Retry). Installed outranks it (a module genuinely on disk is not "failed"), which is why + // this sits just below the isInstalled branch. The two action buttons take on the recovery + // roles: primary Schedule -> Retry (re-fires just this module, user-confirmed, no auto-retry), + // secondary Install now -> Back — the same slot-reuse this switch already does for Recover. + // Retry then bounces to the hub, which observes the queue live; this detail is a one-shot + // snapshot (no observer) and would otherwise sit on a stale "Couldn't install". + if (org.iiab.controller.install.presentation.ModuleQueueRepository.get().current().didFail(c.key())) { + chipRow.addView(chip(getString(R.string.k2go_mod_phase_failed), R.color.k2go_clay)); + schedule.setText(R.string.k2go_home_retry); + schedule.setOnClickListener(v -> { + if (org.iiab.controller.env.EnvironmentLock.isHeld(requireContext())) { + Snackbars.make(v, org.iiab.controller.util.BusyMessage.resFor(requireContext())).show(); + return; + } + org.iiab.controller.install.presentation.InstallService.retryModules( + requireContext(), java.util.Collections.singletonList(c.key())); + requireActivity().getOnBackPressedDispatcher().onBackPressed(); + }); + schedule.setVisibility(View.VISIBLE); + installNowBtn.setText(R.string.k2go_setup_back); + installNowBtn.setOnClickListener(v -> requireActivity().getOnBackPressedDispatcher().onBackPressed()); + installNowBtn.setVisibility(View.VISIBLE); + return; + } if (unknown) { chipRow.addView(chip(getString(R.string.k2go_state_no_answer), R.color.k2go_amber_text)); diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/ModuleHubFragment.java b/controller/app/src/main/java/org/iiab/controller/redesign/ModuleHubFragment.java index 8b4b5d4f2..573796f2d 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/ModuleHubFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/ModuleHubFragment.java @@ -500,25 +500,10 @@ private View cardRow(final ModuleCards.Card c, final boolean isInstalled, LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); tlp.leftMargin = px(10); row.addView(pill, tlp); - // ADFA-4898: a failed module offers a user-confirmed Retry right on the row (no auto-retry). - if (failed) { - com.google.android.material.button.MaterialButton retry = - new com.google.android.material.button.MaterialButton(requireContext(), null, - com.google.android.material.R.attr.materialButtonOutlinedStyle); - retry.setText(R.string.k2go_home_retry); - retry.setOnClickListener(v -> { - if (org.iiab.controller.env.EnvironmentLock.isHeld(requireContext())) { - Snackbars.make(v, org.iiab.controller.util.BusyMessage.resFor(requireContext())).show(); - return; - } - org.iiab.controller.install.presentation.InstallService.retryModules( - requireContext(), java.util.Collections.singletonList(c.key())); - }); - LinearLayout.LayoutParams rblp = new LinearLayout.LayoutParams( - LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); - rblp.leftMargin = px(8); - row.addView(retry, rblp); - } + // ADFA-4898: Retry does NOT live on the hub row. The "Couldn't install" pill is signal enough + // to draw the user into the module; the per-module Retry lives on the module detail (the same + // per-module didFail(key) that colours this pill drives the detail's Retry/Back there), so a + // batch where only one module failed never sprays Retry across the whole list. return row; } diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/ModuleInstallFragment.java b/controller/app/src/main/java/org/iiab/controller/redesign/ModuleInstallFragment.java index 7cf00e011..79d168402 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/ModuleInstallFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/ModuleInstallFragment.java @@ -125,7 +125,10 @@ private void updateStatus() { } } - if (mq.failedModules != null && mq.failedModules.contains(key)) { + // ADFA-4898: one predicate for "did this module fail" across every surface (hub pill, detail + // Retry/Back, this progress line) — ModuleQueueState.didFail(key). Was hand-rolled here as + // failedModules.contains(...); routed through the shared atom so the three can't drift apart. + if (mq.didFail(key)) { terminalDone = true; status.setText(getString(R.string.k2go_mod_phase_failed)); return; diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/SetupProgressActivity.java b/controller/app/src/main/java/org/iiab/controller/redesign/SetupProgressActivity.java index 3573015b5..b661a11c4 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/SetupProgressActivity.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/SetupProgressActivity.java @@ -688,14 +688,21 @@ private void render() { // wording and would suggest we're bringing the server up while runroles own the rootfs. During the // runroles show "Modules are installing"; only the real post-DONE restart says "Starting services". boolean moduleFlow = moduleInSession(); - // Amber "working" while a module install runs or its post-DONE restart is pending — but NOT once it - // failed (that shows the Finish/error controls, no animated wait). - boolean amberWaiting = !moduleServerFailed && (moduleFlow ? !moduleServerUp : !servicesReady); + // ADFA-4898: the module batch reached its terminal with at least one runrole failed. The server + // itself came up (so this is NOT moduleServerFailed), and a green "Adding your content" here read + // the failed batch as a clean success. Keep the SAME amber "installing" header it had before the + // failure — no new copy — and let the failure + Retry surface per-module below (hub pill + detail), + // not in this batch-level header. + boolean moduleFailed = moduleFlow && prootFailed > 0; + // Amber "working" while a module install runs, its post-DONE restart is pending, or the batch + // ended with a failed module (kept on the same amber install line, never a green success). + boolean amberWaiting = !moduleServerFailed && (moduleFailed || (moduleFlow ? !moduleServerUp : !servicesReady)); tint(dot, (amberWaiting || moduleServerFailed) ? R.color.k2go_amber : R.color.k2go_leaf); int statusRes; if (moduleServerFailed) statusRes = R.string.k2go_setup_slow; // couldn't bring services online else if (moduleRestartKicked && !moduleServerUp) statusRes = R.string.k2go_setup_starting; // (re)starting the server else if (moduleFlow && !moduleServerUp) statusRes = R.string.install_busy_modules; // runroles in flight + else if (moduleFailed) statusRes = R.string.install_busy_modules; // ADFA-4898: keep the amber install header; failure + Retry are per-module below else if (moduleFlow) statusRes = R.string.k2go_setup_adding; // module done + server up else if (!servicesReady) statusRes = (readyPolls >= SLOW_AFTER_POLLS ? R.string.k2go_setup_slow : R.string.k2go_setup_starting); else statusRes = R.string.k2go_setup_adding; From df28cf26bbc1552d52b551de66bb0a46360b1573 Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Thu, 27 Aug 2026 15:45:36 -0600 Subject: [PATCH 3/8] ADFA-4898: add Retry to the live module-install card; share one retry action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The failed-state Retry was on the module detail but not on the live install card (the animation + log screen) — exactly where the user watches the runrole fail. Add it there and factor the action so it isn't duplicated. - ModuleRetry: one busy-gated, single-module retry action, called by both the live install card and the module detail so the two can't drift. - ModuleInstallFragment: a filled Retry under the status line, shown only while this module reads "failed" and self-hiding on re-fire (the card observes the queue live). Clears the terminal latch on RUNNING so the status resumes following the log after a retry. - ModuleDetailFragment: route its Retry through the shared ModuleRetry action. --- .../redesign/ModuleDetailFragment.java | 11 ++--- .../redesign/ModuleInstallFragment.java | 13 ++++++ .../iiab/controller/redesign/ModuleRetry.java | 46 +++++++++++++++++++ .../layout/fragment_k2go_module_install.xml | 15 ++++++ 4 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 controller/app/src/main/java/org/iiab/controller/redesign/ModuleRetry.java diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/ModuleDetailFragment.java b/controller/app/src/main/java/org/iiab/controller/redesign/ModuleDetailFragment.java index f0c2149ab..bad14a41d 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/ModuleDetailFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/ModuleDetailFragment.java @@ -155,14 +155,13 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c if (org.iiab.controller.install.presentation.ModuleQueueRepository.get().current().didFail(c.key())) { chipRow.addView(chip(getString(R.string.k2go_mod_phase_failed), R.color.k2go_clay)); schedule.setText(R.string.k2go_home_retry); + // Shared, busy-gated retry (same action the live progress card fires). On a real + // start, bounce to the hub — this detail is a one-shot snapshot with no observer and + // would otherwise sit on a stale "Couldn't install"; the hub reflects the queue live. schedule.setOnClickListener(v -> { - if (org.iiab.controller.env.EnvironmentLock.isHeld(requireContext())) { - Snackbars.make(v, org.iiab.controller.util.BusyMessage.resFor(requireContext())).show(); - return; + if (ModuleRetry.fire(v, c.key())) { + requireActivity().getOnBackPressedDispatcher().onBackPressed(); } - org.iiab.controller.install.presentation.InstallService.retryModules( - requireContext(), java.util.Collections.singletonList(c.key())); - requireActivity().getOnBackPressedDispatcher().onBackPressed(); }); schedule.setVisibility(View.VISIBLE); installNowBtn.setText(R.string.k2go_setup_back); diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/ModuleInstallFragment.java b/controller/app/src/main/java/org/iiab/controller/redesign/ModuleInstallFragment.java index 79d168402..d8c56b90b 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/ModuleInstallFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/ModuleInstallFragment.java @@ -49,6 +49,7 @@ public static ModuleInstallFragment newInstance(String yamlBaseKey) { private String key; private TextView status, logText, logLabel; + private com.google.android.material.button.MaterialButton retryBtn; // ADFA-4898 private View progressRow; // ADFA-5228 private com.google.android.material.progressindicator.LinearProgressIndicator progress; private TextView progressPct, progressEta; @@ -70,6 +71,11 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c title.setText(c != null ? getString(c.detailTitleRes) : (key == null ? "" : key)); status = root.findViewById(R.id.k2go_modinst_status); + // ADFA-4898: retry this one failed module, from the card where the failure is on screen. The + // busy gate + re-fire is the shared ModuleRetry action; visibility is driven live by + // updateStatus() (shown only while this module reads "failed"). + retryBtn = root.findViewById(R.id.k2go_modinst_retry); + retryBtn.setOnClickListener(v -> ModuleRetry.fire(v, key)); progressRow = root.findViewById(R.id.k2go_modinst_progress_row); // ADFA-5228 progress = root.findViewById(R.id.k2go_modinst_progress); progressPct = root.findViewById(R.id.k2go_modinst_progress_pct); @@ -113,6 +119,10 @@ private void updateStatus() { if (status == null || !isAdded()) return; ModuleQueueState mq = ModuleQueueRepository.get().current(); + // ADFA-4898: the retry button tracks the one failed state, live — visible only while this + // module reads "failed", so a re-fire (queue -> RUNNING) hides it again on the next tick. + if (retryBtn != null) retryBtn.setVisibility(mq.didFail(key) ? View.VISIBLE : View.GONE); + // ADFA-5228: determinate bar above the status line while THIS module installs; hidden when // it isn't running or has no task table (percent < 0), leaving the animation alone. if (progressRow != null) { @@ -139,6 +149,9 @@ private void updateStatus() { return; } if (installing()) { + // ADFA-4898: a retry from this card brought the module back to RUNNING — clear the terminal + // latch so the live log resumes driving the status line (it stops again on the next terminal). + terminalDone = false; // live status is driven by the log; keep the phase text only until the first line arrives. if (logLines.isEmpty()) status.setText(getString(R.string.k2go_mod_phase_installing)); return; diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/ModuleRetry.java b/controller/app/src/main/java/org/iiab/controller/redesign/ModuleRetry.java new file mode 100644 index 000000000..b4edb279a --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/redesign/ModuleRetry.java @@ -0,0 +1,46 @@ +/* + * ============================================================================ + * Name : ModuleRetry.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : ADFA-4898. One place for "retry this one failed module". Both the live progress card + * (ModuleInstallFragment) and the module detail (ModuleDetailFragment) call this, so the + * user-confirmed, busy-gated, single-module retry is defined once and can't drift between + * surfaces. The actual re-fire is InstallService.retryModules; this only adds the busy + * gate + the single-key packaging the UI needs. + * ============================================================================ + */ +package org.iiab.controller.redesign; + +import android.content.Context; +import android.view.View; + +import java.util.Collections; + +import org.iiab.controller.env.EnvironmentLock; +import org.iiab.controller.install.presentation.InstallService; +import org.iiab.controller.util.BusyMessage; +import org.iiab.controller.util.Snackbars; + +public final class ModuleRetry { + + private ModuleRetry() {} + + /** + * Re-fire the install of a single module that failed. Gated by the environment lock: if something + * else already owns the rootfs, show a busy snackbar anchored on {@code anchor} and do nothing. + * + * @return true if the retry was actually started (caller may navigate on that), false if it was + * swallowed by the busy gate or the inputs were null. + */ + public static boolean fire(View anchor, String moduleKey) { + if (anchor == null || moduleKey == null) return false; + Context ctx = anchor.getContext(); + if (EnvironmentLock.isHeld(ctx)) { + Snackbars.make(anchor, BusyMessage.resFor(ctx)).show(); + return false; + } + InstallService.retryModules(ctx, Collections.singletonList(moduleKey)); + return true; + } +} diff --git a/controller/app/src/main/res/layout/fragment_k2go_module_install.xml b/controller/app/src/main/res/layout/fragment_k2go_module_install.xml index 3666d754d..3071479bf 100644 --- a/controller/app/src/main/res/layout/fragment_k2go_module_install.xml +++ b/controller/app/src/main/res/layout/fragment_k2go_module_install.xml @@ -99,6 +99,21 @@ android:textAppearance="?attr/textAppearanceBodyMedium" android:textColor="@color/k2go_ink" /> + + + Date: Thu, 27 Aug 2026 16:37:54 -0600 Subject: [PATCH 4/8] ADFA-4898: retry through ModuleProvisioner.startBatch so the install index has its rows retryModules fired ACTION_START_MODULES raw and never saved the ModuleBatch, so a retry brought up the install index empty ("Finishing setup" with no rows) while the queue ran in the background. The wishlist drain got this right; the retry path duplicated the "fire the service" half and dropped the "save the batch" half. - ModuleProvisioner.startBatch: one entry point that saves the ordered batch and hands the keys to InstallService. drain() and retryModules() both go through it, so a batch can't start without the rows the index needs. - InstallService.retryModules: delegates to startBatch (maps keeps its retained per-layer selection as extras) instead of building the intent itself. --- .../install/presentation/InstallService.java | 19 +++++++---- .../redesign/ModuleProvisioner.java | 32 ++++++++++++++----- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/controller/app/src/main/java/org/iiab/controller/install/presentation/InstallService.java b/controller/app/src/main/java/org/iiab/controller/install/presentation/InstallService.java index 8197d3168..5f68dfd1e 100644 --- a/controller/app/src/main/java/org/iiab/controller/install/presentation/InstallService.java +++ b/controller/app/src/main/java/org/iiab/controller/install/presentation/InstallService.java @@ -1069,15 +1069,20 @@ private String mapsInstallCmd() { */ public static void retryModules(Context ctx, java.util.List modules) { if (ctx == null || modules == null || modules.isEmpty()) return; - Intent i = new Intent(ctx, InstallService.class).setAction(ACTION_START_MODULES); - i.putExtra(EXTRA_MODULES, modules.toArray(new String[0])); + // Go through ModuleProvisioner.startBatch (the same path the wishlist drain uses) so the retry + // ALSO records the ModuleBatch. Firing ACTION_START_MODULES raw here skipped the batch save, so + // the install index came up empty ("Finishing setup" with no rows). Maps keeps its retained + // per-layer selection by passing it as extras. + android.os.Bundle extras = null; if (modules.contains("maps") && sRetryMapsVector != null) { - i.putExtra(EXTRA_MAPS_VECTOR, sRetryMapsVector); - i.putExtra(EXTRA_MAPS_SAT, sRetryMapsSat); - i.putExtra(EXTRA_MAPS_TERRAIN, sRetryMapsTerrain); - i.putExtra(EXTRA_MAPS_SEARCH, sRetryMapsSearch); + extras = new android.os.Bundle(); + extras.putString(EXTRA_MAPS_VECTOR, sRetryMapsVector); + extras.putString(EXTRA_MAPS_SAT, sRetryMapsSat); + extras.putString(EXTRA_MAPS_TERRAIN, sRetryMapsTerrain); + extras.putBoolean(EXTRA_MAPS_SEARCH, sRetryMapsSearch); } - androidx.core.content.ContextCompat.startForegroundService(ctx, i); + org.iiab.controller.redesign.ModuleProvisioner.startBatch( + ctx, modules.toArray(new String[0]), extras); } private void finishModuleQueue() { diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/ModuleProvisioner.java b/controller/app/src/main/java/org/iiab/controller/redesign/ModuleProvisioner.java index dfcb19fa8..c7b2b9d33 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/ModuleProvisioner.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/ModuleProvisioner.java @@ -70,18 +70,34 @@ public static OperationDispatcher.Dispatch drain(Context ctx) { + " (" + modules.length + " module(s) still banked)"); return verdict; } - // Record the ordered batch so the install index can render a row per module (the queue only - // reports the current module + remaining, not the full list). - ModuleBatch.save(app, modules); - Intent i = new Intent(app, InstallService.class); - i.setAction(InstallService.ACTION_START_MODULES); - i.putExtra(InstallService.EXTRA_MODULES, modules); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) app.startForegroundService(i); - else app.startService(i); + // Record the ordered batch and hand it to the queue engine — via the one startBatch path, so + // the install index always has its rows (the queue only reports current + remaining). + startBatch(app, modules, null); Log.i(TAG, "module drain: handed " + modules.length + " module(s) to InstallService"); // Handed off; the module-queue owns the run from here. Wishlist cleared; the batch persists // until the index sees the run finish. ModuleWishlist.clear(app); return verdict; } + + /** + * ADFA-4898: the single entry point that starts a module batch. Saves the ordered batch so the + * install index renders a row per module (the queue only reports current + remaining), then hands + * the keys to InstallService. Every caller — the wishlist {@link #drain} and the user-confirmed + * Retry ({@code InstallService.retryModules}) — goes through here, so nothing can start the + * runroles without the batch the index needs. A raw ACTION_START_MODULES that skipped the save + * left the index empty ("Finishing setup" with no rows); centralising it makes that unrepresentable. + * + * @param extras optional intent extras (maps carries its retained per-layer selection here); null otherwise. + */ + public static void startBatch(Context ctx, String[] modules, android.os.Bundle extras) { + if (ctx == null || modules == null || modules.length == 0) return; + final Context app = ctx.getApplicationContext(); + ModuleBatch.save(app, modules); + Intent i = new Intent(app, InstallService.class).setAction(InstallService.ACTION_START_MODULES); + i.putExtra(InstallService.EXTRA_MODULES, modules); + if (extras != null) i.putExtras(extras); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) app.startForegroundService(i); + else app.startService(i); + } } From d925accef7744735c3fb1dd939aafd2a95a31328 Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Thu, 27 Aug 2026 17:07:29 -0600 Subject: [PATCH 5/8] ADFA-4898: put the failed-module Retry in the host detail bar, reusing the Run-in-background/Back template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Retry button sat mid-card, disconnected from the "Back" the host pins at the bottom. Move it into the host's existing two-button detail bar so Retry and Back are one primary/secondary pair, and drop the bespoke in-card button. - SetupProgressActivity.configureDetailBar(): reconfigures the one existing detail template — filled primary (k2go_sp_back) over outlined secondary (k2go_sp_detail_finish). A failed module shows Retry (primary) + Back (secondary); every other detail keeps Back (primary) + Run in background (secondary, LIVE only). Recomputed each render, so a Retry that returns the module to RUNNING restores the normal bar. Retry uses the shared ModuleRetry action and stays on the live card. - ModuleInstallFragment / fragment_k2go_module_install.xml: remove the in-card Retry button (now host-owned); keep the terminal-latch reset so the status resumes following the log after a retry. --- .../redesign/ModuleInstallFragment.java | 10 --- .../redesign/SetupProgressActivity.java | 72 ++++++++++++++----- .../layout/fragment_k2go_module_install.xml | 17 +---- 3 files changed, 59 insertions(+), 40 deletions(-) diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/ModuleInstallFragment.java b/controller/app/src/main/java/org/iiab/controller/redesign/ModuleInstallFragment.java index d8c56b90b..8a5514f9d 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/ModuleInstallFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/ModuleInstallFragment.java @@ -49,7 +49,6 @@ public static ModuleInstallFragment newInstance(String yamlBaseKey) { private String key; private TextView status, logText, logLabel; - private com.google.android.material.button.MaterialButton retryBtn; // ADFA-4898 private View progressRow; // ADFA-5228 private com.google.android.material.progressindicator.LinearProgressIndicator progress; private TextView progressPct, progressEta; @@ -71,11 +70,6 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c title.setText(c != null ? getString(c.detailTitleRes) : (key == null ? "" : key)); status = root.findViewById(R.id.k2go_modinst_status); - // ADFA-4898: retry this one failed module, from the card where the failure is on screen. The - // busy gate + re-fire is the shared ModuleRetry action; visibility is driven live by - // updateStatus() (shown only while this module reads "failed"). - retryBtn = root.findViewById(R.id.k2go_modinst_retry); - retryBtn.setOnClickListener(v -> ModuleRetry.fire(v, key)); progressRow = root.findViewById(R.id.k2go_modinst_progress_row); // ADFA-5228 progress = root.findViewById(R.id.k2go_modinst_progress); progressPct = root.findViewById(R.id.k2go_modinst_progress_pct); @@ -119,10 +113,6 @@ private void updateStatus() { if (status == null || !isAdded()) return; ModuleQueueState mq = ModuleQueueRepository.get().current(); - // ADFA-4898: the retry button tracks the one failed state, live — visible only while this - // module reads "failed", so a re-fire (queue -> RUNNING) hides it again on the next tick. - if (retryBtn != null) retryBtn.setVisibility(mq.didFail(key) ? View.VISIBLE : View.GONE); - // ADFA-5228: determinate bar above the status line while THIS module installs; hidden when // it isn't running or has no task table (percent < 0), leaving the animation alone. if (progressRow != null) { diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/SetupProgressActivity.java b/controller/app/src/main/java/org/iiab/controller/redesign/SetupProgressActivity.java index b661a11c4..ca64c5873 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/SetupProgressActivity.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/SetupProgressActivity.java @@ -83,8 +83,10 @@ public class SetupProgressActivity extends AppCompatActivity implements org.iiab private View dot; private TextView statusText, redirect, cancel, finishNote, contextText; private LinearLayout sections; - private Button finishBtn, runBgBtn, detailRunBgBtn; + private Button finishBtn, runBgBtn, detailRunBgBtn, detailBackBtn; private View detailRoot, indexScroll; + /** ADFA-4898: the key currently shown in the detail host ("mod:", "zim", …), or null on the index. */ + private String detailKey; private final Handler main = new Handler(Looper.getMainLooper()); private boolean servicesReady = false; @@ -150,9 +152,13 @@ protected void onCreate(@Nullable Bundle s) { runBgBtn.setOnClickListener(v -> finish()); // leave; the Library keeps provisioning going cancel.setOnClickListener(v -> { redirectCancelled = true; cancelRedirect(); render(); }); - Button back = findViewById(R.id.k2go_sp_back); - back.setOnClickListener(v -> backToIndex()); + detailBackBtn = findViewById(R.id.k2go_sp_back); detailRunBgBtn = findViewById(R.id.k2go_sp_detail_finish); + // ADFA-4898: the two detail buttons are (re)configured per shown detail by configureDetailBar() + // — normally [Back (primary) / Run in background (secondary, LIVE only)], and for a failed module + // [Retry (primary) / Back (secondary)]. The defaults here cover the window before the first + // configure and any non-module detail. + detailBackBtn.setOnClickListener(v -> backToIndex()); detailRunBgBtn.setText(R.string.k2go_zim_run_bg); // in a detail, secondary = leave (never abort) detailRunBgBtn.setOnClickListener(v -> finish()); @@ -733,6 +739,9 @@ private void render() { // FragmentManager. Deferring costs nothing: onResume posts the poll, which renders. lastAllComplete = allComplete; if (showingDetail) { + // ADFA-4898: keep the detail bar in step with the queue — a module that fails shows Retry, + // and a Retry that puts it back to RUNNING restores Back/Run-in-background on the next tick. + configureDetailBar(); if (allComplete && bounceOnComplete && getLifecycle().getCurrentState().isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) { bounceOnComplete = false; @@ -1267,6 +1276,7 @@ private void goHome(boolean clearSessions) { // ---- detail: host the real per-module card ---- private void openDetail(String key) { showingDetail = true; + detailKey = key; bounceOnComplete = !lastAllComplete; androidx.fragment.app.Fragment f; // Per-key detail view — presentation routing only; the execution class is NOT decided here. @@ -1275,19 +1285,7 @@ private void openDetail(String key) { else if ("kolibri".equals(key)) { f = new KolibriSeedingFragment(); } // ADFA-4954: observe-only else if ("maps".equals(key)) { f = MapsPreparingFragment.newInstance(true); } // ADFA-4901: observe-only else { f = BooksDownloadsFragment.newInstance(true); } - // ADFA-5062: read the execution class from the model instead of re-deriving it from the key - // prefix. A module install is an APP_INSTALL (runs with the box stopped); content rows carry - // their class on ContentType (maps = STOPPED; zim/kolibri/books = LIVE). Only a LIVE op can - // keep running in the background, so the "run in background" button shows only for LIVE - // (ADFA-4919/4842: a stopped-class detail — maps or module — offers only Back). - final boolean live; - if (key.startsWith("mod:")) { - live = Operation.appInstall(key.substring(4)).isLive(); - } else { - ContentType ct = ContentType.byKey(key); - live = ct != null && ct.isLive(); - } - if (detailRunBgBtn != null) detailRunBgBtn.setVisibility(live ? View.VISIBLE : View.GONE); + configureDetailBar(); // ADFA-5074: commitNow, to match backToIndex. With an async commit a render() landing in // between set showingDetail back to false and found nothing to remove, and the queued // transaction then added the fragment into a hidden host — where ZimPreparingFragment @@ -1300,8 +1298,50 @@ private void openDetail(String key) { detailRoot.setVisibility(View.VISIBLE); } + /** + * ADFA-5062: only a LIVE op (zim/kolibri/books) can keep running in the background; a stopped-class + * detail (maps or a module install) cannot. Read from the model, not re-derived from the key prefix. + */ + private boolean isLiveDetail(String key) { + if (key == null) return false; + if (key.startsWith("mod:")) return Operation.appInstall(key.substring(4)).isLive(); + ContentType ct = ContentType.byKey(key); + return ct != null && ct.isLive(); + } + + /** + * ADFA-4898: (re)configure the two-button detail bar for the currently shown detail. Reuses the one + * existing template — a filled primary (k2go_sp_back) over an outlined secondary (k2go_sp_detail_finish): + * - a failed module → Retry (primary) + Back (secondary), so the recovery action sits where the LIVE + * details put Run-in-background, instead of a bespoke button in the card; + * - anything else → Back (primary) + Run in background (secondary, LIVE only). + * Recomputed on every render while a detail is open, so a Retry that puts the module back to RUNNING + * flips the bar back to Back/Run-in-background on the next tick (no stale Retry). Retry stays on the + * card: this detail is the live progress view and follows the re-run with its log. + */ + private void configureDetailBar() { + if (!showingDetail || detailKey == null || detailBackBtn == null) return; + boolean moduleFailed = detailKey.startsWith("mod:") + && ModuleQueueRepository.get().current().didFail(detailKey.substring(4)); + if (moduleFailed) { + final String moduleKey = detailKey.substring(4); + detailBackBtn.setText(R.string.k2go_home_retry); + detailBackBtn.setOnClickListener(v -> ModuleRetry.fire(v, moduleKey)); + detailRunBgBtn.setText(R.string.k2go_setup_back); + detailRunBgBtn.setOnClickListener(v -> backToIndex()); + detailRunBgBtn.setVisibility(View.VISIBLE); + } else { + detailBackBtn.setText(R.string.k2go_setup_back); + detailBackBtn.setOnClickListener(v -> backToIndex()); + detailRunBgBtn.setText(R.string.k2go_zim_run_bg); + detailRunBgBtn.setOnClickListener(v -> finish()); + detailRunBgBtn.setVisibility(isLiveDetail(detailKey) ? View.VISIBLE : View.GONE); + } + } + private void backToIndex() { showingDetail = false; + detailKey = null; // commitNow (synchronous) so the fragment's onDestroyView — which nulls the service // listener — runs BEFORE we reclaim it. With async commit() the teardown fired later and // clobbered the index's listener, so a job finishing while back on the index never updated diff --git a/controller/app/src/main/res/layout/fragment_k2go_module_install.xml b/controller/app/src/main/res/layout/fragment_k2go_module_install.xml index 3071479bf..13e3824dd 100644 --- a/controller/app/src/main/res/layout/fragment_k2go_module_install.xml +++ b/controller/app/src/main/res/layout/fragment_k2go_module_install.xml @@ -99,20 +99,9 @@ android:textAppearance="?attr/textAppearanceBodyMedium" android:textColor="@color/k2go_ink" /> - - + Date: Thu, 27 Aug 2026 17:56:59 -0600 Subject: [PATCH 6/8] ADFA-4898: retry from the module detail lands on the install index, not the hub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retrying from a module detail (reached from Module management) called onBackPressed, dropping the user on the "Add modules" hub, which during an install only shows "Adding content" with no route to the progress. Route to the install index instead — the same destination a normal install reaches via openModuleIndex — so the re-fired batch shows its rows, progress and log. The start action (ModuleRetry) is unchanged; only the detail's post-retry navigation moves. --- .../iiab/controller/redesign/ModuleDetailFragment.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/ModuleDetailFragment.java b/controller/app/src/main/java/org/iiab/controller/redesign/ModuleDetailFragment.java index bad14a41d..f6fc844d4 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/ModuleDetailFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/ModuleDetailFragment.java @@ -155,12 +155,14 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c if (org.iiab.controller.install.presentation.ModuleQueueRepository.get().current().didFail(c.key())) { chipRow.addView(chip(getString(R.string.k2go_mod_phase_failed), R.color.k2go_clay)); schedule.setText(R.string.k2go_home_retry); - // Shared, busy-gated retry (same action the live progress card fires). On a real - // start, bounce to the hub — this detail is a one-shot snapshot with no observer and - // would otherwise sit on a stale "Couldn't install"; the hub reflects the queue live. + // Shared, busy-gated retry (same action the live progress card fires). On a real start, + // land on the install index — the same destination as a normal install (openModuleIndex) + // — where the batch we just re-fired shows its rows, progress and log. NOT onBackPressed: + // that dropped the user on the hub, which during an install only shows "Adding content" + // with no route to the progress (the bug seen retrying from Module management). schedule.setOnClickListener(v -> { if (ModuleRetry.fire(v, c.key())) { - requireActivity().getOnBackPressedDispatcher().onBackPressed(); + startActivity(new android.content.Intent(requireContext(), SetupProgressActivity.class)); } }); schedule.setVisibility(View.VISIBLE); From e851053ffd6b0f0f0cb4e87f1d6884f3496197fd Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Fri, 28 Aug 2026 00:42:42 -0600 Subject: [PATCH 7/8] ADFA-4898: clear the "Couldn't install" notification when the next batch starts The failure notification only auto-cancelled on tap, so a Retry left it beside the new in-progress notification, and it lingered even after the retry succeeded (device test). Cancel it at module-batch start: a retry (or any new install) supersedes it, and the success case is covered because the stale notification is gone before this batch's foreground one is removed by teardown. --- .../controller/install/presentation/InstallService.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/controller/app/src/main/java/org/iiab/controller/install/presentation/InstallService.java b/controller/app/src/main/java/org/iiab/controller/install/presentation/InstallService.java index 5f68dfd1e..afcba8944 100644 --- a/controller/app/src/main/java/org/iiab/controller/install/presentation/InstallService.java +++ b/controller/app/src/main/java/org/iiab/controller/install/presentation/InstallService.java @@ -358,6 +358,12 @@ public int onStartCommand(Intent intent, int flags, int startId) { } startForeground(NOTIFICATION_ID, buildNotification(getString(R.string.install_busy_modules))); + // ADFA-4898: a new batch supersedes any earlier "Couldn't install" notification. Clearing it + // at batch start covers both the retry case (no stale + live notification side by side) and + // the success case (the old failure notification is gone before this batch's foreground one is + // removed by teardown). The failure notification only ever auto-cancelled on tap before. + NotificationManager nmClear = getSystemService(NotificationManager.class); + if (nmClear != null) nmClear.cancel(NOTIFICATION_ID + 4); acquireHardwareLocks(); persistQueue(); // Mark "running" immediately (currentModule null until the first dequeue) so the UI From 335f1a1b04a87a3f56aec8852ef73a7b016119d2 Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Fri, 28 Aug 2026 01:15:23 -0600 Subject: [PATCH 8/8] ADFA-4898: gate the retry through SystemDoor, like the wishlist drain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit retryModules started stopped-class runroles without the SystemDoor readiness check that the drain does, so a retry could run over a system that may not run stopped ops. Add the check to ModuleRetry.fire — the pre-flight home symmetric to drain; it cannot live in the service loop, where InstallGuard's own marker would make the door refuse the install to itself. --- .../install/presentation/InstallService.java | 6 +++-- .../iiab/controller/redesign/ModuleRetry.java | 22 +++++++++++++++---- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/controller/app/src/main/java/org/iiab/controller/install/presentation/InstallService.java b/controller/app/src/main/java/org/iiab/controller/install/presentation/InstallService.java index afcba8944..378053121 100644 --- a/controller/app/src/main/java/org/iiab/controller/install/presentation/InstallService.java +++ b/controller/app/src/main/java/org/iiab/controller/install/presentation/InstallService.java @@ -64,6 +64,8 @@ public final class InstallService extends Service { private static final String TAG = "IIAB-InstallService"; private static final String CHANNEL_ID = "install_channel"; private static final int NOTIFICATION_ID = 3; + /** ADFA-4898: id for the dismissible "Couldn't install" notification (one place, so post and cancel can't drift). */ + private static final int NOTIFICATION_ID_MODULE_FAIL = NOTIFICATION_ID + 4; public static final String ACTION_START = "org.iiab.controller.INSTALL_START"; public static final String ACTION_CANCEL = "org.iiab.controller.INSTALL_CANCEL"; @@ -363,7 +365,7 @@ public int onStartCommand(Intent intent, int flags, int startId) { // the success case (the old failure notification is gone before this batch's foreground one is // removed by teardown). The failure notification only ever auto-cancelled on tap before. NotificationManager nmClear = getSystemService(NotificationManager.class); - if (nmClear != null) nmClear.cancel(NOTIFICATION_ID + 4); + if (nmClear != null) nmClear.cancel(NOTIFICATION_ID_MODULE_FAIL); acquireHardwareLocks(); persistQueue(); // Mark "running" immediately (currentModule null until the first dequeue) so the UI @@ -1128,7 +1130,7 @@ private void postModuleFailureNotification(java.util.List failed) { .setAutoCancel(true) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .build(); - m.notify(NOTIFICATION_ID + 4, n); + m.notify(NOTIFICATION_ID_MODULE_FAIL, n); } private void persistQueue() { diff --git a/controller/app/src/main/java/org/iiab/controller/redesign/ModuleRetry.java b/controller/app/src/main/java/org/iiab/controller/redesign/ModuleRetry.java index b4edb279a..897ddff5f 100644 --- a/controller/app/src/main/java/org/iiab/controller/redesign/ModuleRetry.java +++ b/controller/app/src/main/java/org/iiab/controller/redesign/ModuleRetry.java @@ -19,6 +19,8 @@ import org.iiab.controller.env.EnvironmentLock; import org.iiab.controller.install.presentation.InstallService; +import org.iiab.controller.system.data.SystemDoor; +import org.iiab.controller.system.domain.OperationDispatcher; import org.iiab.controller.util.BusyMessage; import org.iiab.controller.util.Snackbars; @@ -27,11 +29,13 @@ public final class ModuleRetry { private ModuleRetry() {} /** - * Re-fire the install of a single module that failed. Gated by the environment lock: if something - * else already owns the rootfs, show a busy snackbar anchored on {@code anchor} and do nothing. + * Re-fire the install of a single module that failed. Two gates, the same the wishlist drain uses: + * the environment lock (something else owns the rootfs) and the system door (the box may run a + * stopped-class op right now). Either refusal shows a busy snackbar on {@code anchor} and does + * nothing. * - * @return true if the retry was actually started (caller may navigate on that), false if it was - * swallowed by the busy gate or the inputs were null. + * @return true if the retry was actually started (caller may navigate on that), false if a gate + * swallowed it or the inputs were null. */ public static boolean fire(View anchor, String moduleKey) { if (anchor == null || moduleKey == null) return false; @@ -40,6 +44,16 @@ public static boolean fire(View anchor, String moduleKey) { Snackbars.make(anchor, BusyMessage.resFor(ctx)).show(); return false; } + // ADFA-4898: ask the same door the wishlist drain asks before starting stopped-class runroles — + // the box must be in a state that may run them (not NO_SYSTEM / DAMAGED / mid-op). This is the + // gate's only correct home: it is a PRE-FLIGHT check. It cannot move into the service loop, + // because by then InstallGuard has planted the in-progress marker and the door would read our + // own install as "installing" and refuse itself. A retry runs before that begin, so the read is + // clean; and a single small file read on a deliberate tap is within the per-screen I/O budget. + if (!OperationDispatcher.mayRunStopped(SystemDoor.dispatch(ctx, moduleKey))) { + Snackbars.make(anchor, BusyMessage.resFor(ctx)).show(); + return false; + } InstallService.retryModules(ctx, Collections.singletonList(moduleKey)); return true; }