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..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"; @@ -206,6 +208,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,8 +354,18 @@ 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))); + // 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_MODULE_FAIL); acquireHardwareLocks(); persistQueue(); // Mark "running" immediately (currentModule null until the first dequeue) so the UI @@ -1054,6 +1070,29 @@ 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; + // 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) { + 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); + } + org.iiab.controller.redesign.ModuleProvisioner.startBatch( + ctx, modules.toArray(new String[0]), extras); + } + private void finishModuleQueue() { if (finished) return; finished = true; @@ -1062,10 +1101,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_MODULE_FAIL, 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/ModuleDetailFragment.java b/controller/app/src/main/java/org/iiab/controller/redesign/ModuleDetailFragment.java index e59e1c7f7..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 @@ -143,6 +143,34 @@ 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); + // 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())) { + startActivity(new android.content.Intent(requireContext(), SetupProgressActivity.class)); + } + }); + 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 20a0231e6..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 @@ -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,22 @@ 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: 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..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 @@ -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; @@ -136,6 +139,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/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); + } } 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..897ddff5f --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/redesign/ModuleRetry.java @@ -0,0 +1,60 @@ +/* + * ============================================================================ + * 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.system.data.SystemDoor; +import org.iiab.controller.system.domain.OperationDispatcher; +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. 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 a gate + * swallowed it 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; + } + // 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; + } +} 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..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()); @@ -688,14 +694,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; @@ -726,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; @@ -1260,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. @@ -1268,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 @@ -1293,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 3666d754d..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,6 +99,10 @@ android:textAppearance="?attr/textAppearanceBodyMedium" android:textColor="@color/k2go_ink" /> + +