|
| 1 | +/* |
| 2 | + * ============================================================================ |
| 3 | + * Name : SetupDetailHost.java |
| 4 | + * Author : AppDevForAll |
| 5 | + * Copyright : Copyright (c) 2026 AppDevForAll |
| 6 | + * Description : K2GO-434 (slice 6). The per-stream detail host carved out of |
| 7 | + * SetupProgressActivity: opening a row's real detail fragment over the index, the |
| 8 | + * two-button detail action bar (Back / Run-in-background, or Retry / Cancel for a |
| 9 | + * module or seed), and returning to the index. Activity-scoped (owns the index/detail |
| 10 | + * views and a narrow Host for the FragmentManager and the actions a bar can fire). |
| 11 | + * Behavior-preserving: the fragment transactions keep commitNow exactly as before: the |
| 12 | + * listener-reclaim ordering the ADFA-5074 / backToIndex comments protect is unchanged. |
| 13 | + * See controller/docs/ADR-434-setupprogress-decomposition.md. |
| 14 | + * ============================================================================ |
| 15 | + */ |
| 16 | +package org.appdevforall.k2go.redesign; |
| 17 | + |
| 18 | +import android.view.View; |
| 19 | +import android.widget.Button; |
| 20 | + |
| 21 | +import androidx.fragment.app.Fragment; |
| 22 | +import androidx.fragment.app.FragmentManager; |
| 23 | + |
| 24 | +import org.appdevforall.k2go.R; |
| 25 | +import org.appdevforall.k2go.install.presentation.ModuleQueueRepository; |
| 26 | +import org.appdevforall.k2go.install.presentation.ModuleQueueState; |
| 27 | +import org.appdevforall.k2go.kolibri.presentation.KolibriSeedingFragment; |
| 28 | +import org.appdevforall.k2go.system.domain.ContentType; |
| 29 | +import org.appdevforall.k2go.system.domain.Operation; |
| 30 | + |
| 31 | +/** |
| 32 | + * The Activity provides only what the detail host cannot do itself: the FragmentManager for the |
| 33 | + * transactions, a view refresh, and the three actions a configured bar can fire (leave and keep |
| 34 | + * provisioning, retry the Forgejo seed, confirm-cancel a running module). The detail state |
| 35 | + * (showing/key/bounce) and the index/detail view toggle live here. |
| 36 | + */ |
| 37 | +public final class SetupDetailHost { |
| 38 | + |
| 39 | + public interface Host { |
| 40 | + FragmentManager fragmentManager(); |
| 41 | + void render(); |
| 42 | + /** K2GO-382: land on Home; keepSessionsAlive=false leaves the downloads running in the background. */ |
| 43 | + void goHome(boolean keepSessionsAlive); |
| 44 | + /** K2GO-423: re-run a Forgejo seed that gave up (the Retry on the failed seed detail). */ |
| 45 | + void retryForgejoSeed(); |
| 46 | + /** ADFA-4898 P5: confirm, then cancel a running module install. */ |
| 47 | + void confirmCancelModule(); |
| 48 | + } |
| 49 | + |
| 50 | + private final Host host; |
| 51 | + private final View indexScroll; |
| 52 | + private final View detailRoot; |
| 53 | + private final Button detailBackBtn; |
| 54 | + private final Button detailRunBgBtn; |
| 55 | + |
| 56 | + private boolean showingDetail = false; |
| 57 | + /** ADFA-4898: the key currently shown in the detail host ("mod:<k>", "zim", ...), or null on the index. */ |
| 58 | + private String detailKey; |
| 59 | + |
| 60 | + /** |
| 61 | + * ADFA-5074: whether a completed run should take this detail away again. |
| 62 | + * |
| 63 | + * <p>Armed when a detail is opened over work still in flight, cleared when it fires. A detail |
| 64 | + * opened over a run that had already finished is a deliberate look at the result: usually at a |
| 65 | + * failed row, whose retry lives only there, and must not be closed underneath the user. |
| 66 | + */ |
| 67 | + private boolean bounceOnComplete = false; |
| 68 | + |
| 69 | + /** The last completion verdict, so opening a detail can tell "still working" from "finished". */ |
| 70 | + private boolean lastAllComplete = false; |
| 71 | + |
| 72 | + public SetupDetailHost(Host host, View indexScroll, View detailRoot, |
| 73 | + Button detailBackBtn, Button detailRunBgBtn) { |
| 74 | + this.host = host; |
| 75 | + this.indexScroll = indexScroll; |
| 76 | + this.detailRoot = detailRoot; |
| 77 | + this.detailBackBtn = detailBackBtn; |
| 78 | + this.detailRunBgBtn = detailRunBgBtn; |
| 79 | + // ADFA-4898: the two detail buttons are (re)configured per shown detail by configureDetailBar() |
| 80 | + // : normally [Back (primary) / Run in background (secondary, LIVE only)], and for a failed module |
| 81 | + // [Retry (primary) / Back (secondary)]. The defaults here cover the window before the first |
| 82 | + // configure and any non-module detail. |
| 83 | + detailBackBtn.setOnClickListener(v -> backToIndex()); |
| 84 | + detailRunBgBtn.setText(R.string.k2go_zim_run_bg); // in a detail, secondary = leave (never abort) |
| 85 | + detailRunBgBtn.setOnClickListener(v -> host.goHome(false)); // K2GO-382: land on Home, keep provisioning |
| 86 | + } |
| 87 | + |
| 88 | + public boolean isShowingDetail() { return showingDetail; } |
| 89 | + |
| 90 | + /** render() records the latest verdict here every pass, even on the index, so a later openDetail |
| 91 | + * arms the bounce correctly. */ |
| 92 | + public void onVerdict(boolean allComplete) { lastAllComplete = allComplete; } |
| 93 | + |
| 94 | + /** |
| 95 | + * render() detail branch: keep the bar in step with the queue, and bounce back to the index when a |
| 96 | + * run that was still in flight when the detail opened completes while the user is watching. |
| 97 | + * {@code resumed} is passed in so this stays free of the Activity lifecycle. |
| 98 | + */ |
| 99 | + public void refreshWhileShowing(boolean allComplete, boolean resumed) { |
| 100 | + // ADFA-4898: keep the detail bar in step with the queue : a module that fails shows Retry, |
| 101 | + // and a Retry that puts it back to RUNNING restores Back/Run-in-background on the next tick. |
| 102 | + configureDetailBar(); |
| 103 | + if (allComplete && bounceOnComplete && resumed) { |
| 104 | + bounceOnComplete = false; |
| 105 | + backToIndex(); |
| 106 | + } |
| 107 | + } |
| 108 | + |
| 109 | + // ---- detail: host the real per-module card ---- |
| 110 | + public void openDetail(String key) { |
| 111 | + showingDetail = true; |
| 112 | + detailKey = key; |
| 113 | + bounceOnComplete = !lastAllComplete; |
| 114 | + Fragment f; |
| 115 | + // Per-key detail view : presentation routing only; the execution class is NOT decided here. |
| 116 | + if (key.startsWith("mod:")) { f = ModuleInstallFragment.newInstance(key.substring(4)); } // ADFA-4842 |
| 117 | + else if ("zim".equals(key)) { f = new ZimPreparingFragment(); } // ADFA-5074: observe-only |
| 118 | + else if ("kolibri".equals(key)) { f = new KolibriSeedingFragment(); } // ADFA-4954: observe-only |
| 119 | + else if ("forgejo".equals(key)) { f = new org.appdevforall.k2go.forgejo.presentation.ForgejoSeedingFragment(); } // K2GO-423: observe-only |
| 120 | + else if ("maps".equals(key)) { f = MapsPreparingFragment.newInstance(true); } // ADFA-4901: observe-only |
| 121 | + else { f = BooksDownloadsFragment.newInstance(true); } |
| 122 | + configureDetailBar(); |
| 123 | + // ADFA-5074: commitNow, to match backToIndex. With an async commit a render() landing in |
| 124 | + // between set showingDetail back to false and found nothing to remove, and the queued |
| 125 | + // transaction then added the fragment into a hidden host : where ZimPreparingFragment |
| 126 | + // takes the service listener with nobody left to reclaim it, freezing the index's row. |
| 127 | + // The mirror image of the bug backToIndex's commitNow already exists for. Only reachable |
| 128 | + // from a row tap now that the intent routing is gone, so the activity is resumed and the |
| 129 | + // synchronous commit is safe. |
| 130 | + host.fragmentManager().beginTransaction().replace(R.id.k2go_sp_fraghost, f).commitNow(); |
| 131 | + indexScroll.setVisibility(View.GONE); |
| 132 | + detailRoot.setVisibility(View.VISIBLE); |
| 133 | + } |
| 134 | + |
| 135 | + /** |
| 136 | + * ADFA-5062: only a LIVE op (zim/kolibri/books) can keep running in the background; a stopped-class |
| 137 | + * detail (maps or a module install) cannot. Read from the model, not re-derived from the key prefix. |
| 138 | + */ |
| 139 | + private boolean isLiveDetail(String key) { |
| 140 | + if (key == null) return false; |
| 141 | + if (key.startsWith("mod:")) return Operation.appInstall(key.substring(4)).isLive(); |
| 142 | + if ("forgejo".equals(key)) return true; // K2GO-423: the seed is a live, backgroundable step |
| 143 | + ContentType ct = ContentType.byKey(key); |
| 144 | + return ct != null && ct.isLive(); |
| 145 | + } |
| 146 | + |
| 147 | + /** |
| 148 | + * ADFA-4898: (re)configure the two-button detail bar for the currently shown detail. Reuses the one |
| 149 | + * existing template : a filled primary (k2go_sp_back) over an outlined secondary (k2go_sp_detail_finish): |
| 150 | + * - a failed module -> Retry (primary) + Back (secondary), so the recovery action sits where the LIVE |
| 151 | + * details put Run-in-background, instead of a bespoke button in the card; |
| 152 | + * - anything else -> Back (primary) + Run in background (secondary, LIVE only). |
| 153 | + * Recomputed on every render while a detail is open, so a Retry that puts the module back to RUNNING |
| 154 | + * flips the bar back to Back/Run-in-background on the next tick (no stale Retry). Retry stays on the |
| 155 | + * card: this detail is the live progress view and follows the re-run with its log. |
| 156 | + */ |
| 157 | + private void configureDetailBar() { |
| 158 | + if (!showingDetail || detailKey == null || detailBackBtn == null) return; |
| 159 | + // K2GO-423: the Forgejo seed detail offers Retry on failure, mirroring the module Retry. The |
| 160 | + // seed's give-up cleared the banked marker (A), so retryForgejoSeed() re-banks with the same |
| 161 | + // repo opt-in and restarts the service; the bar flips back to Back/Run-in-background on the |
| 162 | + // next tick once the seed is running again. (A durable/one-tap re-seed after leaving is K2GO-422.) |
| 163 | + if ("forgejo".equals(detailKey) |
| 164 | + && org.appdevforall.k2go.forgejo.presentation.ForgejoSeedRepository.get().isFailed()) { |
| 165 | + detailBackBtn.setText(R.string.k2go_home_retry); |
| 166 | + detailBackBtn.setOnClickListener(v -> host.retryForgejoSeed()); |
| 167 | + detailRunBgBtn.setText(R.string.k2go_setup_back); |
| 168 | + detailRunBgBtn.setOnClickListener(v -> backToIndex()); |
| 169 | + detailRunBgBtn.setVisibility(View.VISIBLE); |
| 170 | + return; |
| 171 | + } |
| 172 | + // K2GO-394: the maps detail opens under the legacy key "maps" (not "mod:maps"), but maps IS a |
| 173 | + // module, so treat it as one here -> it gets the same Cancel-while-running (the mockup's op-level |
| 174 | + // Cancel install) and Retry-on-failure the other modules already have, with no duplicated logic. |
| 175 | + final boolean isModule = detailKey.startsWith("mod:") || "maps".equals(detailKey); |
| 176 | + final String moduleKey = !isModule ? null |
| 177 | + : (detailKey.startsWith("mod:") ? detailKey.substring(4) : detailKey); |
| 178 | + ModuleQueueState mq = ModuleQueueRepository.get().current(); |
| 179 | + boolean moduleFailed = isModule && mq.didFail(moduleKey); |
| 180 | + boolean moduleRunning = isModule && mq.isInstalling(moduleKey); |
| 181 | + if (moduleFailed) { |
| 182 | + detailBackBtn.setText(R.string.k2go_home_retry); |
| 183 | + detailBackBtn.setOnClickListener(v -> ModuleRetry.fire(v, moduleKey)); |
| 184 | + detailRunBgBtn.setText(R.string.k2go_setup_back); |
| 185 | + detailRunBgBtn.setOnClickListener(v -> backToIndex()); |
| 186 | + detailRunBgBtn.setVisibility(View.VISIBLE); |
| 187 | + } else if (moduleRunning) { |
| 188 | + // ADFA-4898 P5: while this module's runrole runs, offer a confirmed Cancel in the same |
| 189 | + // secondary slot (Back stays primary). Cancel kills the runrole and surfaces the module as |
| 190 | + // failed, so the Retry above appears on the next tick : the "immediate retry" of the ticket. |
| 191 | + detailBackBtn.setText(R.string.k2go_setup_back); |
| 192 | + detailBackBtn.setOnClickListener(v -> backToIndex()); |
| 193 | + detailRunBgBtn.setText(R.string.k2go_setup_cancel); |
| 194 | + detailRunBgBtn.setOnClickListener(v -> host.confirmCancelModule()); |
| 195 | + detailRunBgBtn.setVisibility(View.VISIBLE); |
| 196 | + } else { |
| 197 | + detailBackBtn.setText(R.string.k2go_setup_back); |
| 198 | + detailBackBtn.setOnClickListener(v -> backToIndex()); |
| 199 | + detailRunBgBtn.setText(R.string.k2go_zim_run_bg); |
| 200 | + detailRunBgBtn.setOnClickListener(v -> host.goHome(false)); // K2GO-382: land on Home, keep provisioning |
| 201 | + detailRunBgBtn.setVisibility(isLiveDetail(detailKey) ? View.VISIBLE : View.GONE); |
| 202 | + } |
| 203 | + } |
| 204 | + |
| 205 | + public void backToIndex() { |
| 206 | + showingDetail = false; |
| 207 | + detailKey = null; |
| 208 | + // commitNow (synchronous) so the fragment's onDestroyView : which nulls the service |
| 209 | + // listener : runs BEFORE we reclaim it. With async commit() the teardown fired later and |
| 210 | + // clobbered the index's listener, so a job finishing while back on the index never updated |
| 211 | + // the UI (spinner stuck) until the card was reopened. |
| 212 | + Fragment cur = host.fragmentManager().findFragmentById(R.id.k2go_sp_fraghost); |
| 213 | + if (cur != null) host.fragmentManager().beginTransaction().remove(cur).commitNow(); |
| 214 | + detailRoot.setVisibility(View.GONE); |
| 215 | + indexScroll.setVisibility(View.VISIBLE); |
| 216 | + ZimDownloadService.setListener(host::render); |
| 217 | + BooksDownloadService.setListener(host::render); |
| 218 | + host.render(); |
| 219 | + } |
| 220 | +} |
0 commit comments