diff --git a/controller/app/src/main/java/org/appdevforall/k2go/redesign/SetupProgressActivity.java b/controller/app/src/main/java/org/appdevforall/k2go/redesign/SetupProgressActivity.java index c6fd99a6..564cdf34 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/redesign/SetupProgressActivity.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/redesign/SetupProgressActivity.java @@ -45,6 +45,7 @@ import org.appdevforall.k2go.kolibri.presentation.KolibriSeedingFragment; import org.appdevforall.k2go.setup.domain.RunScope; import org.appdevforall.k2go.setup.domain.RunSnapshot; +import org.appdevforall.k2go.setup.domain.RebuildUiState; import org.appdevforall.k2go.setup.domain.RunVerdict; import org.appdevforall.k2go.setup.domain.SetupUiState; import org.appdevforall.k2go.setup.domain.StreamState; @@ -76,17 +77,9 @@ public class SetupProgressActivity extends AppCompatActivity implements org.appd * index tracks and waits on the seed even though there is no module install this run. */ public static final String EXTRA_FORGEJO_SEED = "forgejoSeed"; - private static final long READY_POLL_MS = 2000L; private static final long REDIRECT_MS = 3000L; - // ADFA-4900: if the maps module queue never reports RUNNING/DONE this long after hand-off, treat - // it as a start failure so the pipeline can't hang forever waiting on a stage that never began. - private static final long MAPS_START_TIMEOUT_MS = 30000L; - // ADFA-4842: cap the post-module server-restart wait so the index can never trap the user forever - // if the server won't come back up; on timeout we proceed to the Library (which owns recovery). - private static final long SERVER_UP_TIMEOUT_MS = 45000L; - // ADFA-4874: after this many failed readiness polls (~30s at 2s each) the status line switches - // to a soft "taking longer than expected" message, so a stuck engine doesn't look frozen. - private static final int SLOW_AFTER_POLLS = 15; + // K2GO-434: READY_POLL_MS / MAPS_START_TIMEOUT_MS / SERVER_UP_TIMEOUT_MS / SLOW_AFTER_POLLS moved to + // SetupProgressController with the loop; render() reads them as SetupProgressController.. private View dot; private TextView statusText, redirect, cancel, finishNote, contextText; @@ -97,45 +90,20 @@ public class SetupProgressActivity extends AppCompatActivity implements org.appd private String detailKey; private final Handler main = new Handler(Looper.getMainLooper()); - private boolean servicesReady = false; - private boolean drained = false; private boolean redirectCancelled = false; private boolean redirectScheduled = false; private boolean showingDetail = false; private boolean leaveWarned = false; // ADFA-4919 (2c): captured the first exit-Back once - private boolean probing = false; // K2GO-434: the per-run stage latches ("what belongs to this run") live in a small domain state // machine (setup/domain/RunScope); see controller/docs/ADR-434-setupprogress-decomposition.md. private final RunScope runScope = new RunScope(); - private boolean rebuildRunningSeen = false; // ADFA-5011: latched once we've seen THIS rebuild running, - // so a STALE terminal state from a previous rebuild can't trigger a premature done/redirect on entry - // ADFA-5011: after the rebuild build+swap succeeds, WAIT for the REST core to actually answer before - // redirecting — else we land on a dead Home while pdsm-started services are still coming up. We only - // POLL apiReady() here (read-only); the service already did pdsm start, so we never toggle/stop. - private boolean rebuildStartKicked = false; // ADFA-5011: index booted the environment once (actuator) - private boolean rebuildServerUp = false; // REST core answered after the rebuild - private boolean rebuildServerFailed = false; // services didn't answer within the timeout - private long rebuildServerAt = 0L; // elapsedRealtime when the post-success wait began - private boolean mapsLaunched = false; // ADFA-4900: maps (proot) stage has been handed to the queue - private long mapsLaunchedAt = 0L; // ADFA-4900: elapsedRealtime when maps was handed off - private boolean mapsStartFailed = false; // ADFA-4900: queue never started within the timeout - // ADFA-4842: module management (non-maps proot modules) — same shape as the maps stage tracking. - private boolean moduleLaunched = false; - private long moduleLaunchedAt = 0L; - private boolean moduleStartFailed = false; + // K2GO-434 (slice 3b): the provisioning pipeline loop + its latch state live in the controller; + // render() and the *InSession/prootActive predicates read the latches back through its getters. + private SetupProgressController pipeline; private boolean postInstallSeed = false; // K2GO-422: this run was launched to seed repos post-install - private int readyPolls = 0; // ADFA-4874: failed readiness polls so far (slow-start message) - // ADFA-4842: a real module batch stops the server (pdsm stop) for its runroles. When the queue is - // DONE, the index restarts the server and WAITS here — showing "Starting services…" — until the REST - // core answers, then completes/redirects to an already-live Library. Owns a ServerController (Host) - // just to issue that start; the server proot is process-scoped so it survives into LibraryActivity. + // ADFA-4842: the index owns a ServerController (Host) to issue the post-batch server (re)start; the + // server proot is process-scoped so it survives into LibraryActivity. private org.appdevforall.k2go.ServerController serverController; - // ADFA-5343 (Phase 2): the post-batch server restart is owned by the reconciler now, not this screen. - // The three boot latches (moduleRestartKicked/moduleServerUp/moduleServerFailed) are gone: "up" is - // read from the one observed server phase (serverObservedUp()), and "didn't come back in time" is a - // timeout on that phase anchored below — there is no FAILED phase, so a stuck flap is STARTING the - // reconciler keeps re-driving, and Finish still lands on a Home the reconciler drives live (5336). - private long moduleServerWaitAt = 0L; // elapsedRealtime when the post-batch wait began (0 = not yet) private org.appdevforall.k2go.util.EllipsisAnimator statusEllipsis; // ADFA-4842: animated "…" on the amber wait line private int px(int dp) { return Math.round(dp * getResources().getDisplayMetrics().density); } @@ -183,6 +151,19 @@ protected void onCreate(@Nullable Bundle s) { serverController = new org.appdevforall.k2go.ServerController(this, this); serverController.start(); + // K2GO-434 (slice 3b): the provisioning pipeline loop, driven through a narrow Host into this + // Activity (render + the run-scope predicates + the environment boot + a Context for the drains). + pipeline = new SetupProgressController(new SetupProgressController.Host() { + @Override public android.content.Context context() { return SetupProgressActivity.this; } + @Override public boolean isFinishing() { return SetupProgressActivity.this.isFinishing(); } + @Override public void render() { SetupProgressActivity.this.render(); } + @Override public boolean rebuildInSession() { return SetupProgressActivity.this.rebuildInSession(); } + @Override public boolean moduleInSession() { return SetupProgressActivity.this.moduleInSession(); } + @Override public boolean serverObservedUp() { return SetupProgressActivity.this.serverObservedUp(); } + @Override public boolean forgejoSeedActive() { return SetupProgressActivity.this.forgejoSeedActive(); } + @Override public void startEnvironmentBoot() { serverController.startEnvironment(); } + }); + // ADFA-5343 (§3.3 follow-up): an install marker left by a DEAD process launch // (InstallGuard.isInterrupted) is a killed install, not a resumable session. If the OS // restores this index on top after such a kill, resuming here strands it polling for a server @@ -257,8 +238,7 @@ protected void onResume() { // showingDetail already true and never posted the poll at all. Nothing advanced, and the // run could not complete while the user watched it. Removing the callback first keeps // this to one chain, since the runnable re-arms itself. - main.removeCallbacks(readyPoll); - main.post(readyPoll); + pipeline.resume(); if (!showingDetail) { // The listeners are the one thing that IS about who is on screen: while a detail is // open the fragment owns the single listener slot (see backToIndex). @@ -271,7 +251,7 @@ protected void onResume() { @Override protected void onPause() { super.onPause(); - main.removeCallbacks(readyPoll); + pipeline.pause(); if (statusEllipsis != null) statusEllipsis.stop(); // ADFA-4842 cancelRedirect(); if (serverController != null) serverController.onPause(); // ADFA-4842: stop the status poll (not the server) @@ -290,9 +270,9 @@ public void onBackPressed() { // second backgrounds the app (the rebuild keeps going and reopening resumes here). if (rebuildInSession()) { InstallState rst = InstallProgressRepository.get().current(); - boolean rebuiltOk = rebuildRunningSeen && rst.phase == InstallState.Phase.SUCCESS; + boolean rebuiltOk = pipeline.rebuildRunningSeen() && rst.phase == InstallState.Phase.SUCCESS; boolean stillWorking = InstallProgressRepository.get().isRunning() - || (rebuiltOk && !rebuildServerUp && !rebuildServerFailed); + || (rebuiltOk && !pipeline.rebuildServerUp() && !pipeline.rebuildServerFailed()); if (stillWorking) { if (!leaveWarned) { leaveWarned = true; @@ -329,10 +309,10 @@ public void onBackPressed() { * (serverObservedUp) or the wait times out. */ private boolean prootActive() { ModuleQueueState mq = ModuleQueueRepository.get().current(); - boolean mapsTerminal = mapsStartFailed || (mapsInSession() && mq.phase == ModuleQueueState.Phase.DONE); + boolean mapsTerminal = pipeline.mapsStartFailed() || (mapsInSession() && mq.phase == ModuleQueueState.Phase.DONE); boolean mapsActive = mapsInSession() && !mapsTerminal; // A module session stays active from its runroles through the server restart that follows. - boolean moduleActive = (moduleInSession() || moduleStartFailed) && !serverObservedUp(); + boolean moduleActive = (moduleInSession() || pipeline.moduleStartFailed()) && !serverObservedUp(); return mapsActive || moduleActive; } @@ -354,7 +334,7 @@ private boolean mapsInSession() { // render as the "Maps" stage, so latch only on the maps provisioner/launch or a running // queue whose current module is maps. ModuleQueueState mq = ModuleQueueRepository.get().current(); - return runScope.latchMaps(mapsLaunched || mapsStartFailed + return runScope.latchMaps(pipeline.mapsLaunched() || pipeline.mapsStartFailed() || MapsProvisioner.hasPending(this) || (ModuleQueueRepository.get().isRunning() && "maps".equals(mq.currentModule))); } @@ -364,7 +344,7 @@ private boolean mapsInSession() { * still renders the module rows and reaches completion. */ private boolean moduleInSession() { ModuleQueueState mq = ModuleQueueRepository.get().current(); - return runScope.latchModule(moduleLaunched || moduleStartFailed + return runScope.latchModule(pipeline.moduleLaunched() || pipeline.moduleStartFailed() || ModuleProvisioner.hasPending(this) || ModuleBatch.has(this) || (ModuleQueueRepository.get().isRunning() && mq.currentModule != null && !"maps".equals(mq.currentModule))); @@ -404,236 +384,6 @@ private boolean rebuildInSession() { return runScope.latchRebuild(signal); } - // ---- readiness gate + serialized install pipeline (ADFA-4900) ---- - // Once the REST engine is up, run the install tasks as an ORDERED, serialized pipeline: - // maps (proot / runrole) exclusively first, then ZIM, then Books, auto-continuing between - // stages HERE (never dropping to Home/Library mid-sequence). Keep polling until every stage - // has been started and finished; render() then auto-advances (or shows Finish on failure). - private final Runnable readyPoll = new Runnable() { - @Override public void run() { - if (probing) return; - if (isFinishing()) return; - // ADFA-5011: a dashboard rebuild owns the rootfs (the service does pdsm stop → build → swap and - // leaves the box STOPPED). Skip the normal install readiness/orchestrate path entirely — it has - // nothing to drain and would see the server still up in the first seconds, declare "nothing to - // do" and redirect (the original bug). Instead: re-render from the rebuild state; and once the - // rebuild is terminal, the INDEX boots the environment persistently and waits for it (below). - if (rebuildInSession()) { - InstallState cur = InstallProgressRepository.get().current(); - boolean rebuiltOk = rebuildRunningSeen && cur.phase == InstallState.Phase.SUCCESS; - boolean rebuiltFail = rebuildRunningSeen && cur.phase == InstallState.Phase.FAILED; - // Rebuild done → the INDEX is the actuator that boots the environment PERSISTENTLY - // (startEnvironment = 'pdsm start && tail -f /dev/null'), exactly like the module flow. - // The rebuild service left the box stopped (its transient proots would kill any service - // they started via --kill-on-exit), so nothing else brings it up. Kick it exactly once. - if ((rebuiltOk || rebuiltFail) && !rebuildStartKicked) { - rebuildStartKicked = true; - rebuildServerAt = SystemClock.elapsedRealtime(); - serverController.startEnvironment(); - } - render(); // sets rebuildRunningSeen once the running state is observed - // On success, probe the REST core (read-only) until it answers or the wait times out — - // so we redirect only once services are truly up, never onto a dead Home. Reschedule from - // INSIDE the probe callback (not below): apiReady() can block up to ~5s while the server - // boots — longer than READY_POLL_MS — and the top `if (probing) return` would otherwise - // strand the loop if we also scheduled here. - if (rebuiltOk && !rebuildServerUp && !rebuildServerFailed) { - probing = true; - AppExecutors.get().io().execute(() -> { - final boolean up = RestReadiness.apiReady(); - main.post(() -> { - probing = false; - if (isFinishing()) return; - if (up) rebuildServerUp = true; - else if (SystemClock.elapsedRealtime() - rebuildServerAt > SERVER_UP_TIMEOUT_MS) rebuildServerFailed = true; - render(); - if (!rebuildServerUp && !rebuildServerFailed) main.postDelayed(readyPoll, READY_POLL_MS); - }); - }); - return; - } - // Building, or terminal-and-settled. Keep polling only while still building. - boolean settled = rebuiltFail || (rebuiltOk && (rebuildServerUp || rebuildServerFailed)); - if (!settled) main.postDelayed(readyPoll, READY_POLL_MS); - return; - } - // ADFA-4842: a MODULE (solo-proot) install stops the server and runs its OWN proot — there is - // no REST engine to wait for, and we must NEVER try to "start services" (a second proot) mid- - // runrole. Skip the REST readiness gate entirely: the runrole queue drives progress, and the - // server is (re)started only AFTER the queue is DONE — now by the reconciler (desired=UP), - // observed here via render(). REST and REST+proot (mixed) keep their serialized apiReady path - // below, untouched. - if (moduleInSession()) { - orchestrateStep(); // drains on first entry; harmless no-op once the queue is running - render(); - // K2GO-423: keep polling past server-up while a Forgejo seed is pending/running -- it - // starts only once the server is observed up (a live dash-node), and must be driven to - // completion here, not left for a silent Home drain. - if (!serverObservedUp() || forgejoSeedActive()) main.postDelayed(readyPoll, READY_POLL_MS); - return; - } - // ADFA-5074: nothing to start means nothing to wait for. The readiness probe exists so - // we never POST a job before the engine answers — it is a gate on STARTING work. When - // every stream is already in flight there is no job to post, and the probe stops being - // free: it is an HTTP request to a server that is busy serving the very download it is - // being asked about. Observed on device with the network throttled — a finished ZIM and - // a finished Courses run both sat under "Starting services." with a green Done row, - // because apiReady() kept timing out at 2.5s, servicesReady stayed false, orchestrateStep - // never ran, `drained` was never set, and the completion the screen was waiting for could - // not be reached. Both redirected the moment the link freed up, minutes late. - // - // So: if no provisioner has anything pending, the pipeline has nothing to launch and can - // advance on what it can already see. This is also the truthful answer — the box is - // demonstrably up, it is downloading — and it stops the header claiming otherwise. - if (!servicesReady && nothingToStart()) servicesReady = true; - - // Once the engine is confirmed up, advance the pipeline on the main thread without - // re-checking apiReady() over HTTP every tick (the build can run for hours). ADFA-4900/#6. - if (servicesReady) { - boolean moreWork = orchestrateStep(); - render(); - if (moreWork) main.postDelayed(readyPoll, READY_POLL_MS); - return; - } - probing = true; - AppExecutors.get().io().execute(() -> { - final boolean ready = RestReadiness.apiReady(); - main.post(() -> { - probing = false; - if (isFinishing()) return; - if (!ready) { - readyPolls++; // ADFA-4874: feeds the slow-start message in render() - render(); - main.postDelayed(readyPoll, READY_POLL_MS); - return; - } - servicesReady = true; - boolean moreWork = orchestrateStep(); - render(); - if (moreWork) main.postDelayed(readyPoll, READY_POLL_MS); - }); - }); - } - }; - - /** - * ADFA-5074: no stage has anything left to launch. - * - *

Exactly the set of "hasPending" questions {@link #orchestrateStep()} asks before it - * starts anything, and asked in one place so the two cannot drift: if this is true, that - * method can only observe. Deliberately not "is the run finished" — work already in flight - * is not pending, which is the whole point. A Get More download reaches this screen with its - * wishlist already drained by the door, so there is nothing to gate. - */ - private boolean nothingToStart() { - return !MapsProvisioner.hasPending(this) - && !ModuleProvisioner.hasPending(this) - && !ZimProvisioner.hasPending(this) - && !BooksProvisioner.hasPending(this) - && !KolibriProvisioner.hasPending(this) - && !forgejoSeedActive(); // K2GO-423: a banked/running Forgejo seed is work to finish - } - - /** - * ADFA-5061: whether a drain actually handed work over. - * - *

Three outcomes, and the pipeline needs them apart. {@code null} means the drain was not - * attempted — nothing banked, or a queue already running — and nothing has changed, so the - * caller must not mark a stage either way. {@code RUN_STOPPED} means it went. Anything else - * is the model refusing, and a refusal does not become a yes by asking again on the next - * tick: the facts it read are the facts, and only a repair or an install changes them. - */ - private static boolean launched(OperationDispatcher.Dispatch verdict) { - return verdict != null && OperationDispatcher.mayRunStopped(verdict); - } - - /** - * ADFA-4900: one step of the serialized install pipeline. Starts the next stage only when the - * previous one has finished; proot (maps) runs exclusively before any REST download, so Ansible's - * background forks never overlap a live REST job. Returns true while work remains (keep polling), - * false once every stage has been started and is complete. - */ - private boolean orchestrateStep() { - ModuleQueueState mq = ModuleQueueRepository.get().current(); - boolean queueRunning = ModuleQueueRepository.get().isRunning(); - - // Stage 1 — proot (maps and/or module management), exclusive of all REST work (proot tasks - // run serially via the queue). Maps (Get More) and modules (module management) are separate - // entry points, so at most one has a pending batch in a given session. - // ADFA-5061: `!mapsStartFailed` retires the stage. Until the drains could refuse, a - // pending wishlist always became an empty one on the next pass, so "still pending" was a - // safe reason to keep waiting. It is not any more: a refusal leaves the order banked on - // purpose, and without this guard the stage would be re-offered every READY_POLL_MS - // forever — a spinner with no explanation, on precisely the damaged system that needs one. - if (!mapsStartFailed && MapsProvisioner.hasPending(this)) { - if (!queueRunning) { - if (launched(MapsProvisioner.drain(this))) { - mapsLaunched = true; mapsLaunchedAt = SystemClock.elapsedRealtime(); - } else { - // Same terminal state as a queue that never started: render() already treats - // it as a failed proot stage, which is the visible answer decision 4 asks for. - mapsStartFailed = true; - } - } - return true; - } - if (!moduleStartFailed && ModuleProvisioner.hasPending(this)) { // ADFA-4842: module management batch - if (!queueRunning) { - if (launched(ModuleProvisioner.drain(this))) { - moduleLaunched = true; moduleLaunchedAt = SystemClock.elapsedRealtime(); - } else { - moduleStartFailed = true; - } - } - return true; - } - if (queueRunning) return true; // a runrole in flight - if (mapsLaunched && !mapsStartFailed && mq.phase != ModuleQueueState.Phase.DONE) { - // Launched but the queue hasn't reported RUNNING/DONE yet. Wait, but fail closed if it - // never starts (ADFA-4900/#1) so the pipeline can't hang on a stage that never began. - if (SystemClock.elapsedRealtime() - mapsLaunchedAt > MAPS_START_TIMEOUT_MS) mapsStartFailed = true; - else return true; - } - if (moduleLaunched && !moduleStartFailed && mq.phase != ModuleQueueState.Phase.DONE) { - if (SystemClock.elapsedRealtime() - moduleLaunchedAt > MAPS_START_TIMEOUT_MS) moduleStartFailed = true; - else return true; - } - - // Stage 2 — REST. ADFA-4954 (ADR-4954 D8): the three live streams now serialize against - // each other as well. Each measures free space independently and at a different moment, - // so all three could pass their own check and jointly fill the disk. Each provisioner - // defers while another holds a session; calling them in a fixed order means the first - // one starts and the rest retry on a later pass. Keep polling until all are complete. - if (ZimProvisioner.hasPending(this)) ZimProvisioner.drain(this); - if (BooksProvisioner.hasPending(this)) BooksProvisioner.drain(this); - if (KolibriProvisioner.hasPending(this)) KolibriProvisioner.drain(this); - // K2GO-423: the Forgejo seed is a chained REST-stage sibling. It starts only when: - // - the forgejo runrole SUCCEEDED (queue DONE and forgejo not in the failed set). A failed or - // stalled attempt reaches this Stage 2 terminal too, and starting the seed there would find - // forgejo absent and clear the banked marker -- so a later retry would never re-seed - // (observed on device: a 360s binary-download stall cleared the seed); - // - the module server is observed up (a live dash-node under proot), so the POST reaches a - // working box; and no session is open yet (the service owns its bounded retry). - // This replaces the old silent Home drain (ForgejoSeedProvisioner). - if (org.appdevforall.k2go.forgejo.data.ForgejoInstallPrefs.isSeedPending(this) - && mq.phase == ModuleQueueState.Phase.DONE && !mq.didFail("forgejo") - && serverObservedUp() - && !org.appdevforall.k2go.forgejo.presentation.ForgejoSeedRepository.get().hasSession()) { - org.appdevforall.k2go.forgejo.presentation.ForgejoSeedService.start(this); - } - KolibriSeedRepository kolibri = KolibriSeedRepository.get(); - boolean restBusy = (ZimDownloadService.hasSession() && !ZimDownloadService.isComplete()) - || (BooksDownloadService.hasSession() && !BooksDownloadService.isComplete()) - || (kolibri.hasSession() && !kolibri.isComplete()) - || ZimProvisioner.hasPending(this) || BooksProvisioner.hasPending(this) - || KolibriProvisioner.hasPending(this) - || forgejoSeedActive(); // K2GO-423: keep the pipeline busy until the seed is terminal - if (restBusy) return true; - - // Every stage has been started and is complete. - drained = true; - return false; - } // ---- render ---- private void render() { @@ -730,7 +480,7 @@ private void render() { // ADFA-4842: proot = maps OR a module batch. A proot-only run finishes when the queue is // terminal, without waiting on any REST drain. boolean prootShown = mapsShown || moduleShown; - boolean prootTerminal = mapsStartFailed || moduleStartFailed + boolean prootTerminal = pipeline.mapsStartFailed() || pipeline.moduleStartFailed() || (prootShown && mq.phase == ModuleQueueState.Phase.DONE); // ADFA-4919: a proot module is queued/running (the gate is active). boolean prootActive = prootActive(); @@ -750,8 +500,8 @@ private void render() { // settling = terminal, not up yet, still within the timeout ((re)starting). boolean batchServerUp = moduleShown && queueTerminalNotRunning && serverObservedUp(); boolean batchAwaitingServer = moduleShown && queueTerminalNotRunning && !serverObservedUp(); - boolean batchServerSlow = batchAwaitingServer && moduleServerWaitAt != 0L - && SystemClock.elapsedRealtime() - moduleServerWaitAt > SERVER_UP_TIMEOUT_MS; + boolean batchServerSlow = batchAwaitingServer && pipeline.moduleServerWaitAt() != 0L + && SystemClock.elapsedRealtime() - pipeline.moduleServerWaitAt() > SetupProgressController.SERVER_UP_TIMEOUT_MS; boolean batchServerSettling = batchAwaitingServer && !batchServerSlow; boolean moduleServerSettled = batchServerUp || batchServerSlow; // ADFA-5343: up, or gave up waiting here @@ -778,11 +528,11 @@ private void render() { // clean, finished download instead of redirecting to the library. Only read the // queue's verdict when it belongs to THIS run — the same three signals prootTerminal // already uses, so the two stay in agreement. - boolean queueVerdictIsOurs = prootShown || mapsStartFailed || moduleStartFailed; + boolean queueVerdictIsOurs = prootShown || pipeline.mapsStartFailed() || pipeline.moduleStartFailed(); int prootFailed = !queueVerdictIsOurs ? 0 : (mq.phase == ModuleQueueState.Phase.DONE) ? (mq.failedModules == null ? 0 : mq.failedModules.size()) - : ((mapsStartFailed ? 1 : 0) + (moduleStartFailed ? 1 : 0)); + : ((pipeline.mapsStartFailed() ? 1 : 0) + (pipeline.moduleStartFailed() ? 1 : 0)); // K2GO-434: the completion + success/failure rule is now a pure domain use case // (setup/domain/RunVerdict). This pass only GATHERS the inputs from the live // repositories/services; the rule is unit-tested off device. See @@ -792,7 +542,7 @@ private void render() { boolean forgejoSeedFailed = forgejoSeedInSession() && org.appdevforall.k2go.forgejo.presentation.ForgejoSeedRepository.get().isFailed(); RunVerdict verdict = RunVerdict.of(new RunSnapshot.Builder() - .noRest(noRest).prootShown(prootShown).moduleShown(moduleShown).drained(drained) + .noRest(noRest).prootShown(prootShown).moduleShown(moduleShown).drained(pipeline.drained()) .queueTerminalNotRunning(queueTerminalNotRunning).moduleServerSettled(moduleServerSettled) .batchServerSlow(batchServerSlow).seedPendingRun(seedPendingRun) .zim(new StreamState(zimSession, zimSession && ZimDownloadService.isComplete(), zimFailed)) @@ -824,9 +574,9 @@ private void render() { SetupUiState ui = new SetupUiState.Inputs() .batchServerSlow(batchServerSlow).batchServerSettling(batchServerSettling) .batchServerUp(batchServerUp).moduleFlow(moduleFlow).moduleFailed(moduleFailed) - .servicesReady(servicesReady).slowByPolls(readyPolls >= SLOW_AFTER_POLLS) + .servicesReady(pipeline.servicesReady()).slowByPolls(pipeline.readyPolls() >= SetupProgressController.SLOW_AFTER_POLLS) .success(verdict.success()).failure(verdict.failure()).redirectCancelled(redirectCancelled) - .runInBackgroundEnabled((servicesReady || forgejoSeedActive()) && !prootActive) + .runInBackgroundEnabled((pipeline.servicesReady() || forgejoSeedActive()) && !prootActive) .build(); tint(dot, ui.tone == SetupUiState.StatusTone.WAITING ? R.color.k2go_amber : R.color.k2go_leaf); int statusRes = statusStringRes(ui.message); @@ -915,58 +665,70 @@ private int statusStringRes(SetupUiState.StatusMessage m) { private void renderRebuild() { InstallState st = InstallProgressRepository.get().current(); - if (st.isRunning()) rebuildRunningSeen = true; + if (st.isRunning()) pipeline.markRebuildRunningSeen(); // Only honor a terminal state once THIS rebuild has been seen running — otherwise a stale // SUCCESS/FAILED from a previous rebuild would flash on entry and trigger a premature redirect. - boolean rebuiltOk = rebuildRunningSeen && st.phase == InstallState.Phase.SUCCESS; - boolean rebuildFailed = rebuildRunningSeen && st.phase == InstallState.Phase.FAILED; - - // Note: the post-success wait for the REST core (apiReady poll) is driven by readyPoll, which is - // lifecycle-managed (posted in onResume, cleared in onPause). This method only reflects state. - boolean serverWait = rebuiltOk && !rebuildServerUp && !rebuildServerFailed; // rebuilt, services coming up - boolean done = rebuiltOk && rebuildServerUp; // rebuilt + REST core answered - boolean error = rebuildFailed || (rebuiltOk && rebuildServerFailed); // rebuild failed, or services never came up - boolean working = !done && !error; // building OR waiting for services + boolean rebuiltOk = pipeline.rebuildRunningSeen() && st.phase == InstallState.Phase.SUCCESS; + boolean rebuildFailed = pipeline.rebuildRunningSeen() && st.phase == InstallState.Phase.FAILED; + // K2GO-434: the rebuild state -> view decision is a pure rule (setup/domain/RebuildUiState). + // The apiReady wait that feeds rebuildServerUp/Failed is driven by readyPoll (lifecycle-managed); + // this method only reflects state and maps the phase to the row, status line and controls. + RebuildUiState rb = RebuildUiState.from( + rebuiltOk, rebuildFailed, pipeline.rebuildServerUp(), pipeline.rebuildServerFailed(), redirectCancelled); String sub; - if (error) sub = rebuildFailed - ? ((st.message != null && !st.message.isEmpty()) ? st.message : getString(R.string.k2go_dash_rebuild_failed)) - : getString(R.string.k2go_dash_services_failed); - else if (done) sub = getString(R.string.k2go_setup_state_done); - else if (serverWait) sub = getString(R.string.k2go_setup_starting); - else sub = getString(R.string.k2go_dash_rebuild_building); + switch (rb.phase) { + case ERROR: + sub = rb.errorIsRebuildFailure + ? ((st.message != null && !st.message.isEmpty()) ? st.message : getString(R.string.k2go_dash_rebuild_failed)) + : getString(R.string.k2go_dash_services_failed); + break; + case DONE: sub = getString(R.string.k2go_setup_state_done); break; + case SERVER_WAIT: sub = getString(R.string.k2go_setup_starting); break; + case BUILDING: + default: sub = getString(R.string.k2go_dash_rebuild_building); break; + } sections.removeAllViews(); - sections.addView(rebuildRow(rebuiltOk && !error, error, sub)); // check once the build succeeded; alert on error + boolean check = rb.phase == RebuildUiState.Phase.DONE || rb.phase == RebuildUiState.Phase.SERVER_WAIT; + sections.addView(rebuildRow(check, rb.phase == RebuildUiState.Phase.ERROR, sub)); if (contextText != null) contextText.setText(R.string.k2go_setup_context_proot); - tint(dot, done ? R.color.k2go_leaf : R.color.k2go_amber); + tint(dot, rb.phase == RebuildUiState.Phase.DONE ? R.color.k2go_leaf : R.color.k2go_amber); + boolean working = rb.phase == RebuildUiState.Phase.BUILDING || rb.phase == RebuildUiState.Phase.SERVER_WAIT; if (working) { - statusEllipsis.start(getString(serverWait ? R.string.k2go_setup_starting : R.string.k2go_dash_rebuilding)); + statusEllipsis.start(getString(rb.phase == RebuildUiState.Phase.SERVER_WAIT + ? R.string.k2go_setup_starting : R.string.k2go_dash_rebuilding)); } else { statusEllipsis.stop(); - statusText.setText(done ? R.string.k2go_setup_state_done - : (rebuildFailed ? R.string.k2go_dash_rebuild_failed : R.string.k2go_dash_services_failed)); + statusText.setText(rb.phase == RebuildUiState.Phase.DONE ? R.string.k2go_setup_state_done + : (rb.errorIsRebuildFailure ? R.string.k2go_dash_rebuild_failed : R.string.k2go_dash_services_failed)); } - if (done && !redirectCancelled) { - redirect.setText(R.string.k2go_dash_redirect); // rebuild-specific wording (not "Installation complete") - show(redirect, true); show(cancel, true); - show(finishBtn, false); show(finishNote, false); show(runBgBtn, false); - scheduleRedirect(); - } else if (done) { // cancelled by the user — stay, reveal Finish - cancelRedirect(); - show(finishBtn, true); show(runBgBtn, false); - show(redirect, false); show(cancel, false); show(finishNote, false); - } else if (error) { - cancelRedirect(); - show(finishBtn, true); show(finishNote, true); show(runBgBtn, false); - show(redirect, false); show(cancel, false); - } else { // building or waiting for services — the screen is the gate; no leaving. - cancelRedirect(); - show(runBgBtn, false); show(finishBtn, false); show(finishNote, false); - show(redirect, false); show(cancel, false); + switch (rb.controls) { + case REDIRECT: + redirect.setText(R.string.k2go_dash_redirect); // rebuild-specific wording (not "Installation complete") + show(redirect, true); show(cancel, true); + show(finishBtn, false); show(finishNote, false); show(runBgBtn, false); + scheduleRedirect(); + break; + case FINISH_SUCCESS: // cancelled by the user: stay, reveal Finish + cancelRedirect(); + show(finishBtn, true); show(runBgBtn, false); + show(redirect, false); show(cancel, false); show(finishNote, false); + break; + case FINISH_FAILURE: + cancelRedirect(); + show(finishBtn, true); show(finishNote, true); show(runBgBtn, false); + show(redirect, false); show(cancel, false); + break; + case GATED: + default: // building or waiting for services: the screen is the gate, no leaving + cancelRedirect(); + show(runBgBtn, false); show(finishBtn, false); show(finishNote, false); + show(redirect, false); show(cancel, false); + break; } } @@ -1224,10 +986,10 @@ private int booksOverallPercent() { private View mapsRow() { ModuleQueueState mq = ModuleQueueRepository.get().current(); - boolean done = mapsStartFailed || (mapsInSession() && mq.phase == ModuleQueueState.Phase.DONE); + boolean done = pipeline.mapsStartFailed() || (mapsInSession() && mq.phase == ModuleQueueState.Phase.DONE); boolean running = !done && (ModuleQueueRepository.get().isRunning() || (mapsInSession() && mq.phase != ModuleQueueState.Phase.DONE)); - boolean failed = mapsStartFailed || (done && mq.failedModules.contains("maps")); + boolean failed = pipeline.mapsStartFailed() || (done && mq.failedModules.contains("maps")); boolean started = running || done; LinearLayout row = new LinearLayout(this); @@ -1291,7 +1053,7 @@ private View moduleRow(String key) { ModuleCards.Card c = ModuleCards.byKey(key); String name = c != null ? getString(c.titleRes) : key; - boolean failed = (mq.failedModules != null && mq.failedModules.contains(key)) || moduleStartFailed; + boolean failed = (mq.failedModules != null && mq.failedModules.contains(key)) || pipeline.moduleStartFailed(); boolean queueDone = mq.phase == ModuleQueueState.Phase.DONE; boolean running = !queueDone && !failed && key.equals(mq.currentModule) && mq.phase == ModuleQueueState.Phase.RUNNING; @@ -1401,8 +1163,7 @@ private void cancelRedirect() { * timestamp anchors the "taking longer" UI. When actuation is disabled (rollback), we boot once here * as before (and Home is a monitor again, so 5336 is not fixed in that mode). */ private void onModuleBatchTerminal() { - if (moduleServerWaitAt != 0L) return; // once — also the timeout anchor - moduleServerWaitAt = SystemClock.elapsedRealtime(); + if (!pipeline.beginModuleServerWait()) return; // once: also the "taking longer" timeout anchor new org.appdevforall.k2go.Preferences(this).setWatchdogEnable(true); // persisted intent → desired = UP if (!org.appdevforall.k2go.env.ServerLifecycleReconciler.ACTUATES) { serverController.startEnvironment(); // rollback path: reconciler is log-only, boot here @@ -1559,7 +1320,7 @@ private void retryForgejoSeed() { boolean includeRepos = org.appdevforall.k2go.forgejo.presentation.ForgejoSeedRepository.get().includeRepos(); org.appdevforall.k2go.forgejo.data.ForgejoInstallPrefs.bankSeed(this, includeRepos); org.appdevforall.k2go.forgejo.presentation.ForgejoSeedService.start(this); - main.post(readyPoll); + pipeline.kick(); } /** diff --git a/controller/app/src/main/java/org/appdevforall/k2go/redesign/SetupProgressController.java b/controller/app/src/main/java/org/appdevforall/k2go/redesign/SetupProgressController.java new file mode 100644 index 00000000..f20e014d --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/redesign/SetupProgressController.java @@ -0,0 +1,325 @@ +/* + * ============================================================================ + * Name : SetupProgressController.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : K2GO-434 (slice 3b). The provisioning pipeline loop carved out of + * SetupProgressActivity: the readiness/orchestration Runnable, the serialized + * drain (orchestrateStep) and the pipeline latch state. Activity-scoped (holds the + * Activity through a narrow Host, cleared in onDestroy), so it keeps the same + * lifecycle the fields had (the Activity itself survives config-changes, so no + * ViewModel is needed here). Behavior-preserving: the loop logic is moved verbatim, + * with the shared latches read back by the Activity through getters. See + * controller/docs/ADR-434-setupprogress-decomposition.md. + * ============================================================================ + */ +package org.appdevforall.k2go.redesign; + +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; + +import org.appdevforall.k2go.install.presentation.InstallProgressRepository; +import org.appdevforall.k2go.install.presentation.InstallState; +import org.appdevforall.k2go.install.presentation.ModuleQueueRepository; +import org.appdevforall.k2go.install.presentation.ModuleQueueState; +import org.appdevforall.k2go.kolibri.presentation.KolibriProvisioner; +import org.appdevforall.k2go.kolibri.presentation.KolibriSeedRepository; +import org.appdevforall.k2go.system.domain.OperationDispatcher; +import org.appdevforall.k2go.util.AppExecutors; + +/** + * The Activity provides only what the loop cannot do itself: the view refresh, the finishing check, + * the run-scope predicates (backed by RunScope + the repositories), the environment boot, and a + * Context for the provisioner drains. + */ +public final class SetupProgressController { + + public interface Host { + Context context(); + boolean isFinishing(); + void render(); + boolean rebuildInSession(); + boolean moduleInSession(); + boolean serverObservedUp(); + boolean forgejoSeedActive(); + /** ADFA-4842/5011: boot the environment persistently (pdsm start), i.e. serverController.startEnvironment(). */ + void startEnvironmentBoot(); + } + + static final long READY_POLL_MS = 2000L; + // ADFA-4900: if the maps module queue never reports RUNNING/DONE this long after hand-off, treat + // it as a start failure so the pipeline can't hang forever waiting on a stage that never began. + static final long MAPS_START_TIMEOUT_MS = 30000L; + // ADFA-4842: cap the post-module server-restart wait so the index can never trap the user forever + // if the server won't come back up; on timeout we proceed to the Library (which owns recovery). + static final long SERVER_UP_TIMEOUT_MS = 45000L; + // ADFA-4874: after this many failed readiness polls (~30s at 2s each) the status line switches + // to a soft "taking longer than expected" message, so a stuck engine doesn't look frozen. + static final int SLOW_AFTER_POLLS = 15; + + private final Host host; + private final Handler main = new Handler(Looper.getMainLooper()); + + private boolean probing = false; + private boolean servicesReady = false; + private boolean drained = false; + private int readyPolls = 0; // ADFA-4874: failed readiness polls so far (slow-start message) + private boolean mapsLaunched = false; // ADFA-4900: maps (proot) stage has been handed to the queue + private long mapsLaunchedAt = 0L; // ADFA-4900: elapsedRealtime when maps was handed off + private boolean mapsStartFailed = false; // ADFA-4900: queue never started within the timeout + private boolean moduleLaunched = false; // ADFA-4842: same shape as the maps stage tracking + private long moduleLaunchedAt = 0L; + private boolean moduleStartFailed = false; + private boolean rebuildRunningSeen = false; // ADFA-5011: latched once THIS rebuild is seen running + private boolean rebuildStartKicked = false; // ADFA-5011: index booted the environment once (actuator) + private boolean rebuildServerUp = false; // REST core answered after the rebuild + private boolean rebuildServerFailed = false; // services didn't answer within the timeout + private long rebuildServerAt = 0L; // elapsedRealtime when the post-success wait began + private long moduleServerWaitAt = 0L; // elapsedRealtime when the post-batch wait began (0 = not yet) + + public SetupProgressController(Host host) { + this.host = host; + } + + // ---- lifecycle (posted in onResume, cleared in onPause / onDestroy) ---- + /** ADFA-5074: (re)arm the loop. Removing the callback first keeps this to one chain, since the + * runnable re-arms itself. */ + public void resume() { + main.removeCallbacks(readyPoll); + main.post(readyPoll); + } + + public void pause() { + main.removeCallbacks(readyPoll); + } + + /** Kick the loop once (used after a retry re-banks work). */ + public void kick() { + main.post(readyPoll); + } + + // ---- readiness gate + serialized install pipeline (ADFA-4900) ---- + // Once the REST engine is up, run the install tasks as an ORDERED, serialized pipeline: + // maps (proot / runrole) exclusively first, then ZIM, then Books, auto-continuing between + // stages HERE (never dropping to Home/Library mid-sequence). Keep polling until every stage + // has been started and finished; render() then auto-advances (or shows Finish on failure). + private final Runnable readyPoll = new Runnable() { + @Override public void run() { + if (probing) return; + if (host.isFinishing()) return; + // ADFA-5011: a dashboard rebuild owns the rootfs (the service does pdsm stop -> build -> swap and + // leaves the box STOPPED). Skip the normal install readiness/orchestrate path entirely: it has + // nothing to drain and would see the server still up in the first seconds, declare "nothing to + // do" and redirect (the original bug). Instead: re-render from the rebuild state; and once the + // rebuild is terminal, the INDEX boots the environment persistently and waits for it (below). + if (host.rebuildInSession()) { + InstallState cur = InstallProgressRepository.get().current(); + boolean rebuiltOk = rebuildRunningSeen && cur.phase == InstallState.Phase.SUCCESS; + boolean rebuiltFail = rebuildRunningSeen && cur.phase == InstallState.Phase.FAILED; + // Rebuild done -> the INDEX is the actuator that boots the environment PERSISTENTLY + // (startEnvironment = 'pdsm start && tail -f /dev/null'), exactly like the module flow. + // The rebuild service left the box stopped (its transient proots would kill any service + // they started via --kill-on-exit), so nothing else brings it up. Kick it exactly once. + if ((rebuiltOk || rebuiltFail) && !rebuildStartKicked) { + rebuildStartKicked = true; + rebuildServerAt = SystemClock.elapsedRealtime(); + host.startEnvironmentBoot(); + } + host.render(); // sets rebuildRunningSeen once the running state is observed + // On success, probe the REST core (read-only) until it answers or the wait times out, so + // we redirect only once services are truly up, never onto a dead Home. Reschedule from + // INSIDE the probe callback (not below): apiReady() can block up to ~5s while the server + // boots, longer than READY_POLL_MS, and the top `if (probing) return` would otherwise + // strand the loop if we also scheduled here. + if (rebuiltOk && !rebuildServerUp && !rebuildServerFailed) { + probing = true; + AppExecutors.get().io().execute(() -> { + final boolean up = RestReadiness.apiReady(); + main.post(() -> { + probing = false; + if (host.isFinishing()) return; + if (up) rebuildServerUp = true; + else if (SystemClock.elapsedRealtime() - rebuildServerAt > SERVER_UP_TIMEOUT_MS) rebuildServerFailed = true; + host.render(); + if (!rebuildServerUp && !rebuildServerFailed) main.postDelayed(readyPoll, READY_POLL_MS); + }); + }); + return; + } + // Building, or terminal-and-settled. Keep polling only while still building. + boolean settled = rebuiltFail || (rebuiltOk && (rebuildServerUp || rebuildServerFailed)); + if (!settled) main.postDelayed(readyPoll, READY_POLL_MS); + return; + } + // ADFA-4842: a MODULE (solo-proot) install stops the server and runs its OWN proot: there is + // no REST engine to wait for, and we must NEVER try to "start services" (a second proot) mid- + // runrole. Skip the REST readiness gate entirely: the runrole queue drives progress, and the + // server is (re)started only AFTER the queue is DONE, now by the reconciler (desired=UP), + // observed here via render(). REST and REST+proot (mixed) keep their serialized apiReady path + // below, untouched. + if (host.moduleInSession()) { + orchestrateStep(); // drains on first entry; harmless no-op once the queue is running + host.render(); + // K2GO-423: keep polling past server-up while a Forgejo seed is pending/running: it + // starts only once the server is observed up (a live dash-node), and must be driven to + // completion here, not left for a silent Home drain. + if (!host.serverObservedUp() || host.forgejoSeedActive()) main.postDelayed(readyPoll, READY_POLL_MS); + return; + } + // ADFA-5074: nothing to start means nothing to wait for. The readiness probe exists so we + // never POST a job before the engine answers: it is a gate on STARTING work. When every + // stream is already in flight there is no job to post, and the probe stops being free (an + // HTTP request to a server busy serving that very download). So: if no provisioner has + // anything pending, the pipeline has nothing to launch and can advance on what it can see. + if (!servicesReady && nothingToStart()) servicesReady = true; + + // Once the engine is confirmed up, advance the pipeline on the main thread without + // re-checking apiReady() over HTTP every tick (the build can run for hours). ADFA-4900/#6. + if (servicesReady) { + boolean moreWork = orchestrateStep(); + host.render(); + if (moreWork) main.postDelayed(readyPoll, READY_POLL_MS); + return; + } + probing = true; + AppExecutors.get().io().execute(() -> { + final boolean ready = RestReadiness.apiReady(); + main.post(() -> { + probing = false; + if (host.isFinishing()) return; + if (!ready) { + readyPolls++; // ADFA-4874: feeds the slow-start message in render() + host.render(); + main.postDelayed(readyPoll, READY_POLL_MS); + return; + } + servicesReady = true; + boolean moreWork = orchestrateStep(); + host.render(); + if (moreWork) main.postDelayed(readyPoll, READY_POLL_MS); + }); + }); + } + }; + + /** + * ADFA-5074: no stage has anything left to launch. Exactly the "hasPending" questions + * orchestrateStep() asks before it starts anything, asked in one place so the two cannot drift. + */ + private boolean nothingToStart() { + Context c = host.context(); + return !MapsProvisioner.hasPending(c) + && !ModuleProvisioner.hasPending(c) + && !ZimProvisioner.hasPending(c) + && !BooksProvisioner.hasPending(c) + && !KolibriProvisioner.hasPending(c) + && !host.forgejoSeedActive(); // K2GO-423: a banked/running Forgejo seed is work to finish + } + + /** ADFA-5061: whether a drain actually handed work over (see the original for the three outcomes). */ + private static boolean launched(OperationDispatcher.Dispatch verdict) { + return verdict != null && OperationDispatcher.mayRunStopped(verdict); + } + + /** + * ADFA-4900: one step of the serialized install pipeline. Starts the next stage only when the + * previous one has finished; proot (maps) runs exclusively before any REST download. Returns true + * while work remains (keep polling), false once every stage has been started and is complete. + */ + private boolean orchestrateStep() { + Context c = host.context(); + ModuleQueueState mq = ModuleQueueRepository.get().current(); + boolean queueRunning = ModuleQueueRepository.get().isRunning(); + + // Stage 1: proot (maps and/or module management), exclusive of all REST work. + if (!mapsStartFailed && MapsProvisioner.hasPending(c)) { + if (!queueRunning) { + if (launched(MapsProvisioner.drain(c))) { + mapsLaunched = true; mapsLaunchedAt = SystemClock.elapsedRealtime(); + } else { + mapsStartFailed = true; + } + } + return true; + } + if (!moduleStartFailed && ModuleProvisioner.hasPending(c)) { // ADFA-4842: module management batch + if (!queueRunning) { + if (launched(ModuleProvisioner.drain(c))) { + moduleLaunched = true; moduleLaunchedAt = SystemClock.elapsedRealtime(); + } else { + moduleStartFailed = true; + } + } + return true; + } + if (queueRunning) return true; // a runrole in flight + if (mapsLaunched && !mapsStartFailed && mq.phase != ModuleQueueState.Phase.DONE) { + // Launched but the queue hasn't reported RUNNING/DONE yet. Wait, but fail closed if it + // never starts (ADFA-4900/#1) so the pipeline can't hang on a stage that never began. + if (SystemClock.elapsedRealtime() - mapsLaunchedAt > MAPS_START_TIMEOUT_MS) mapsStartFailed = true; + else return true; + } + if (moduleLaunched && !moduleStartFailed && mq.phase != ModuleQueueState.Phase.DONE) { + if (SystemClock.elapsedRealtime() - moduleLaunchedAt > MAPS_START_TIMEOUT_MS) moduleStartFailed = true; + else return true; + } + + // Stage 2: REST. ADFA-4954: the three live streams serialize against each other (each defers + // while another holds a session); calling them in a fixed order means the first one starts and + // the rest retry on a later pass. Keep polling until all are complete. + if (ZimProvisioner.hasPending(c)) ZimProvisioner.drain(c); + if (BooksProvisioner.hasPending(c)) BooksProvisioner.drain(c); + if (KolibriProvisioner.hasPending(c)) KolibriProvisioner.drain(c); + // K2GO-423: the Forgejo seed is a chained REST-stage sibling. It starts only when the forgejo + // runrole SUCCEEDED (queue DONE, forgejo not failed), the module server is observed up, and no + // session is open yet. This replaces the old silent Home drain (ForgejoSeedProvisioner). + if (org.appdevforall.k2go.forgejo.data.ForgejoInstallPrefs.isSeedPending(c) + && mq.phase == ModuleQueueState.Phase.DONE && !mq.didFail("forgejo") + && host.serverObservedUp() + && !org.appdevforall.k2go.forgejo.presentation.ForgejoSeedRepository.get().hasSession()) { + org.appdevforall.k2go.forgejo.presentation.ForgejoSeedService.start(c); + } + KolibriSeedRepository kolibri = KolibriSeedRepository.get(); + boolean restBusy = (ZimDownloadService.hasSession() && !ZimDownloadService.isComplete()) + || (BooksDownloadService.hasSession() && !BooksDownloadService.isComplete()) + || (kolibri.hasSession() && !kolibri.isComplete()) + || ZimProvisioner.hasPending(c) || BooksProvisioner.hasPending(c) + || KolibriProvisioner.hasPending(c) + || host.forgejoSeedActive(); // K2GO-423: keep the pipeline busy until the seed is terminal + if (restBusy) return true; + + // Every stage has been started and is complete. + drained = true; + return false; + } + + // ---- state read back by the Activity's render()/predicates (loop is the sole writer) ---- + public boolean servicesReady() { return servicesReady; } + public boolean drained() { return drained; } + public int readyPolls() { return readyPolls; } + public boolean mapsLaunched() { return mapsLaunched; } + public boolean mapsStartFailed() { return mapsStartFailed; } + public boolean moduleLaunched() { return moduleLaunched; } + public boolean moduleStartFailed() { return moduleStartFailed; } + public boolean rebuildServerUp() { return rebuildServerUp; } + public boolean rebuildServerFailed() { return rebuildServerFailed; } + public long moduleServerWaitAt() { return moduleServerWaitAt; } + + /** ADFA-5011: renderRebuild() latches this once it observes the running state. */ + public void markRebuildRunningSeen() { rebuildRunningSeen = true; } + public boolean rebuildRunningSeen() { return rebuildRunningSeen; } + + /** + * ADFA-5343 (Phase 2): a module batch stopped the server for its runroles; when the batch is + * terminal the server should come back. Record the wait anchor once (also the "taking longer" + * timeout anchor). The caller (Activity) sets desired=UP and, in the reconciler-rollback path, + * boots here. Returns true the first time (so the caller performs its one-time side effects). + */ + public boolean beginModuleServerWait() { + if (moduleServerWaitAt != 0L) return false; + moduleServerWaitAt = SystemClock.elapsedRealtime(); + return true; + } +} diff --git a/controller/app/src/main/java/org/appdevforall/k2go/setup/domain/RebuildUiState.java b/controller/app/src/main/java/org/appdevforall/k2go/setup/domain/RebuildUiState.java new file mode 100644 index 00000000..7b91576a --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/setup/domain/RebuildUiState.java @@ -0,0 +1,56 @@ +/* + * ============================================================================ + * Name : RebuildUiState.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : K2GO-434. The dashboard-rebuild sub-mode's state -> view decision as a pure rule: + * which phase the rebuild is in and which bottom controls to show. Semantic enums only + * (no Android), so renderRebuild() maps the phase to the row, the status line and the + * controls, and this stays unit-testable on a plain JVM. Slice 5 of + * controller/docs/ADR-434-setupprogress-decomposition.md. + * ============================================================================ + */ +package org.appdevforall.k2go.setup.domain; + +public final class RebuildUiState { + + /** BUILDING: the rebuild is compiling. SERVER_WAIT: rebuilt, waiting for the REST core. DONE: up. + * ERROR: the rebuild failed, or the server never came up. */ + public enum Phase { BUILDING, SERVER_WAIT, DONE, ERROR } + + public enum Controls { REDIRECT, FINISH_SUCCESS, FINISH_FAILURE, GATED } + + public final Phase phase; + /** Meaningful only when phase == ERROR: the rebuild itself failed (true) vs the services never + * came up after a successful rebuild (false). */ + public final boolean errorIsRebuildFailure; + public final Controls controls; + + private RebuildUiState(Phase phase, boolean errorIsRebuildFailure, Controls controls) { + this.phase = phase; + this.errorIsRebuildFailure = errorIsRebuildFailure; + this.controls = controls; + } + + public static RebuildUiState from(boolean rebuiltOk, boolean rebuildFailed, + boolean rebuildServerUp, boolean rebuildServerFailed, + boolean redirectCancelled) { + boolean serverWait = rebuiltOk && !rebuildServerUp && !rebuildServerFailed; + boolean done = rebuiltOk && rebuildServerUp; + boolean error = rebuildFailed || (rebuiltOk && rebuildServerFailed); + + Phase phase; + if (error) phase = Phase.ERROR; + else if (done) phase = Phase.DONE; + else if (serverWait) phase = Phase.SERVER_WAIT; + else phase = Phase.BUILDING; + + Controls controls; + if (done && !redirectCancelled) controls = Controls.REDIRECT; + else if (done) controls = Controls.FINISH_SUCCESS; // countdown cancelled by the user + else if (error) controls = Controls.FINISH_FAILURE; + else controls = Controls.GATED; // building/waiting: the screen is the gate + + return new RebuildUiState(phase, rebuildFailed, controls); + } +} diff --git a/controller/app/src/test/java/org/appdevforall/k2go/setup/domain/RebuildUiStateTest.java b/controller/app/src/test/java/org/appdevforall/k2go/setup/domain/RebuildUiStateTest.java new file mode 100644 index 00000000..caebf257 --- /dev/null +++ b/controller/app/src/test/java/org/appdevforall/k2go/setup/domain/RebuildUiStateTest.java @@ -0,0 +1,53 @@ +package org.appdevforall.k2go.setup.domain; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** K2GO-434: the dashboard-rebuild state -> view rule (slice 5). Pure, so a plain JVM test. + * from(rebuiltOk, rebuildFailed, rebuildServerUp, rebuildServerFailed, redirectCancelled). */ +public class RebuildUiStateTest { + + @Test + public void buildingIsGated() { + RebuildUiState rb = RebuildUiState.from(false, false, false, false, false); + assertEquals(RebuildUiState.Phase.BUILDING, rb.phase); + assertEquals(RebuildUiState.Controls.GATED, rb.controls); + } + + @Test + public void rebuiltWaitingForServerIsServerWaitGated() { + RebuildUiState rb = RebuildUiState.from(true, false, false, false, false); + assertEquals(RebuildUiState.Phase.SERVER_WAIT, rb.phase); + assertEquals(RebuildUiState.Controls.GATED, rb.controls); + } + + @Test + public void rebuiltAndServerUpRedirectsUntilCancelled() { + RebuildUiState go = RebuildUiState.from(true, false, true, false, false); + assertEquals(RebuildUiState.Phase.DONE, go.phase); + assertEquals(RebuildUiState.Controls.REDIRECT, go.controls); + + RebuildUiState cancelled = RebuildUiState.from(true, false, true, false, true); + assertEquals(RebuildUiState.Phase.DONE, cancelled.phase); + assertEquals(RebuildUiState.Controls.FINISH_SUCCESS, cancelled.controls); + } + + @Test + public void rebuildFailureIsErrorRebuildFailure() { + RebuildUiState rb = RebuildUiState.from(false, true, false, false, false); + assertEquals(RebuildUiState.Phase.ERROR, rb.phase); + assertTrue(rb.errorIsRebuildFailure); + assertEquals(RebuildUiState.Controls.FINISH_FAILURE, rb.controls); + } + + @Test + public void servicesNeverCameUpIsErrorNotRebuildFailure() { + RebuildUiState rb = RebuildUiState.from(true, false, false, true, false); + assertEquals(RebuildUiState.Phase.ERROR, rb.phase); + assertFalse(rb.errorIsRebuildFailure); // rebuild succeeded, the server did not come up + assertEquals(RebuildUiState.Controls.FINISH_FAILURE, rb.controls); + } +}