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 013daf97..c6fd99a6 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 @@ -43,6 +43,11 @@ import org.appdevforall.k2go.kolibri.presentation.KolibriSeedService; import org.appdevforall.k2go.kolibri.presentation.KolibriSeedState; 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.RunVerdict; +import org.appdevforall.k2go.setup.domain.SetupUiState; +import org.appdevforall.k2go.setup.domain.StreamState; import org.appdevforall.k2go.system.data.PendingContent; import org.appdevforall.k2go.system.domain.OperationDispatcher; import org.appdevforall.k2go.system.domain.ContentType; @@ -99,7 +104,9 @@ public class SetupProgressActivity extends AppCompatActivity implements org.appd private boolean showingDetail = false; private boolean leaveWarned = false; // ADFA-4919 (2c): captured the first exit-Back once private boolean probing = false; - private boolean rebuildSeen = false; // ADFA-5011: latched once this screen is a rebuild session + // 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 @@ -112,13 +119,10 @@ public class SetupProgressActivity extends AppCompatActivity implements org.appd 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 mapsSeen = false; // ADFA-4919: latched once the proot (maps) stage is seen // 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; - private boolean moduleSeen = false; // latched once a non-maps proot batch is seen - private boolean forgejoSeedSeen = false; // K2GO-423: latched once a Forgejo seed belongs to this session 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 @@ -350,12 +354,9 @@ 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(); - if (mapsLaunched || mapsStartFailed + return runScope.latchMaps(mapsLaunched || mapsStartFailed || MapsProvisioner.hasPending(this) - || (ModuleQueueRepository.get().isRunning() && "maps".equals(mq.currentModule))) { - mapsSeen = true; - } - return mapsSeen; + || (ModuleQueueRepository.get().isRunning() && "maps".equals(mq.currentModule))); } /** ADFA-4842: is a non-maps proot module batch part of THIS session? Latched from the durable @@ -363,13 +364,10 @@ private boolean mapsInSession() { * still renders the module rows and reaches completion. */ private boolean moduleInSession() { ModuleQueueState mq = ModuleQueueRepository.get().current(); - if (moduleLaunched || moduleStartFailed + return runScope.latchModule(moduleLaunched || moduleStartFailed || ModuleProvisioner.hasPending(this) || ModuleBatch.has(this) - || (ModuleQueueRepository.get().isRunning() && mq.currentModule != null && !"maps".equals(mq.currentModule))) { - moduleSeen = true; - } - return moduleSeen; + || (ModuleQueueRepository.get().isRunning() && mq.currentModule != null && !"maps".equals(mq.currentModule))); } /** K2GO-423: a Forgejo seed belongs to THIS session — a seed is banked, or its service is running. @@ -378,11 +376,9 @@ private boolean moduleInSession() { * run-in-background seed lingers in the process-scoped singleton, and latching on it would draw a * stale seed row in a later, unrelated install. */ private boolean forgejoSeedInSession() { - if (org.appdevforall.k2go.forgejo.data.ForgejoInstallPrefs.isSeedPending(this) - || org.appdevforall.k2go.forgejo.presentation.ForgejoSeedRepository.get().isRunning()) { - forgejoSeedSeen = true; - } - return forgejoSeedSeen; + return runScope.latchForgejoSeed( + org.appdevforall.k2go.forgejo.data.ForgejoInstallPrefs.isSeedPending(this) + || org.appdevforall.k2go.forgejo.presentation.ForgejoSeedRepository.get().isRunning()); } /** K2GO-423: the seed still needs to run (banked) or is running, so completion must wait for it. @@ -401,11 +397,11 @@ private boolean forgejoSeedActive() { * the rebuild runs; a stale terminal REBUILD is excluded by the isRunning() check). Once latched it * stays for the screen's life so the terminal result (done/failed) is shown, not skipped. */ private boolean rebuildInSession() { - if (rebuildSeen) return true; - if (getIntent() != null && getIntent().getBooleanExtra(EXTRA_REBUILD, false)) { rebuildSeen = true; return true; } - if (InstallProgressRepository.get().currentOp() == InstallState.Op.REBUILD - && InstallProgressRepository.get().isRunning()) { rebuildSeen = true; return true; } - return false; + if (runScope.rebuild()) return true; + boolean signal = (getIntent() != null && getIntent().getBooleanExtra(EXTRA_REBUILD, false)) + || (InstallProgressRepository.get().currentOp() == InstallState.Op.REBUILD + && InstallProgressRepository.get().isRunning()); + return runScope.latchRebuild(signal); } // ---- readiness gate + serialized install pipeline (ADFA-4900) ---- @@ -758,7 +754,6 @@ private void render() { && SystemClock.elapsedRealtime() - moduleServerWaitAt > SERVER_UP_TIMEOUT_MS; boolean batchServerSettling = batchAwaitingServer && !batchServerSlow; - boolean allComplete; boolean moduleServerSettled = batchServerUp || batchServerSlow; // ADFA-5343: up, or gave up waiting here // K2GO-423: the seed blocks completion only while it can actually make progress: // - only in a module (forgejo install) flow -- that is the only flow whose batch-terminal -> @@ -775,19 +770,6 @@ private void render() { // The forgejo runrole failing releases the gate ONLY in a module-install flow; a // post-install seed run (postInstallSeed) must not read a stale/unrelated queue verdict. && !(moduleShown && mq.didFail("forgejo")); - if (noRest && prootShown) { - // proot-only: complete when the queue is terminal — plus, for a module batch, once the server - // is back (up) or the restart has failed (a dead home that wakes up seconds later is exactly - // what we're avoiding; a real failure is surfaced as Finish/error below, not a silent success). - allComplete = queueTerminalNotRunning && (!moduleShown || moduleServerSettled) && !seedPendingRun; - } else { - allComplete = drained - && (!zimSession || ZimDownloadService.isComplete()) - && (!booksSession || BooksDownloadService.isComplete()) - && (!kolibriState.hasSession() || kolibriState.isComplete()) // ADFA-4954 - && (!moduleShown || moduleServerSettled) // ADFA-4842: also wait for the module server restart - && !seedPendingRun; // K2GO-423: wait for the chained seed - } // ADFA-4900/4842: failed proot runroles count as failures too (Finish, not a false success). // On DONE the queue's failedModules covers maps + modules; before DONE, a start-timeout counts. // ADFA-4954: ModuleQueueRepository is process-scoped, so a DONE phase left by an @@ -801,13 +783,25 @@ private void render() { : (mq.phase == ModuleQueueState.Phase.DONE) ? (mq.failedModules == null ? 0 : mq.failedModules.size()) : ((mapsStartFailed ? 1 : 0) + (moduleStartFailed ? 1 : 0)); - int failedTotal = failedCount(zimSession ? ZimDownloadService.status() : null, ZimDownloadService.FAILED) - + failedCount(booksSession ? BooksDownloadService.status() : null, BooksDownloadService.FAILED) - + kolibriState.failedCount() // ADFA-4954 - + prootFailed - // K2GO-423: a seed that gave up is surfaced (Finish + note), never a silent redirect. - + (forgejoSeedInSession() - && org.appdevforall.k2go.forgejo.presentation.ForgejoSeedRepository.get().isFailed() ? 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 + // controller/docs/ADR-434-setupprogress-decomposition.md (slice 1). + int zimFailed = failedCount(zimSession ? ZimDownloadService.status() : null, ZimDownloadService.FAILED); + int booksFailed = failedCount(booksSession ? BooksDownloadService.status() : null, BooksDownloadService.FAILED); + 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) + .queueTerminalNotRunning(queueTerminalNotRunning).moduleServerSettled(moduleServerSettled) + .batchServerSlow(batchServerSlow).seedPendingRun(seedPendingRun) + .zim(new StreamState(zimSession, zimSession && ZimDownloadService.isComplete(), zimFailed)) + .books(new StreamState(booksSession, booksSession && BooksDownloadService.isComplete(), booksFailed)) + .kolibri(new StreamState(kolibriState.hasSession(), + kolibriState.hasSession() && kolibriState.isComplete(), kolibriState.failedCount())) + .prootFailed(prootFailed).forgejoSeedFailed(forgejoSeedFailed) + .build()); + boolean allComplete = verdict.allComplete(); // Status dot + line. While waiting, a long-stuck engine shows a softer "taking longer" // message instead of "Starting services" so it doesn't look frozen (ADFA-4874). ADFA-4842: while @@ -824,18 +818,20 @@ private void render() { 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 = !batchServerSlow && (moduleFailed || (moduleFlow ? !batchServerUp : !servicesReady)); - tint(dot, (amberWaiting || batchServerSlow) ? R.color.k2go_amber : R.color.k2go_leaf); - int statusRes; - if (batchServerSlow) statusRes = R.string.k2go_setup_slow; // couldn't bring services online in time - else if (batchServerSettling) statusRes = R.string.k2go_setup_starting; // reconciler is (re)starting the server - else if (moduleFlow && !batchServerUp) 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; + // K2GO-434: the status tone/message + the bottom-controls mode are a pure view-state rule + // (setup/domain/SetupUiState). This pass gathers inputs; the Activity maps the result to + // resources (here) and to show()/scheduleRedirect()/cancelRedirect() (in the controls block). + SetupUiState ui = new SetupUiState.Inputs() + .batchServerSlow(batchServerSlow).batchServerSettling(batchServerSettling) + .batchServerUp(batchServerUp).moduleFlow(moduleFlow).moduleFailed(moduleFailed) + .servicesReady(servicesReady).slowByPolls(readyPolls >= SLOW_AFTER_POLLS) + .success(verdict.success()).failure(verdict.failure()).redirectCancelled(redirectCancelled) + .runInBackgroundEnabled((servicesReady || forgejoSeedActive()) && !prootActive) + .build(); + tint(dot, ui.tone == SetupUiState.StatusTone.WAITING ? R.color.k2go_amber : R.color.k2go_leaf); + int statusRes = statusStringRes(ui.message); // Animate a "…" (dots appear/disappear) on the amber waiting line so it never looks frozen. - if (amberWaiting) statusEllipsis.start(getString(statusRes)); + if (ui.animate) statusEllipsis.start(getString(statusRes)); else { statusEllipsis.stop(); statusText.setText(statusRes); } // ADFA-5074: a detail card is covering the index. Everything above still had to be @@ -874,33 +870,31 @@ && getLifecycle().getCurrentState().isAtLeast(androidx.lifecycle.Lifecycle.State // Bottom controls. ADFA-4842: a failed post-module server restart counts as a failure (Finish + // note), never a silent success — so the user is told, not dropped on a dead Home. - boolean success = allComplete && failedTotal == 0 && !batchServerSlow; - boolean failure = allComplete && (failedTotal > 0 || batchServerSlow); - if (success && !redirectCancelled) { - show(redirect, true); show(cancel, true); - show(finishBtn, false); show(finishNote, false); show(runBgBtn, false); - scheduleRedirect(); - } else if (success) { // cancelled by the user - cancelRedirect(); - show(finishBtn, true); show(runBgBtn, false); - show(redirect, false); show(cancel, false); show(finishNote, false); - } else if (failure) { - cancelRedirect(); - show(finishBtn, true); show(finishNote, true); show(runBgBtn, false); - show(redirect, false); show(cancel, false); - } else { // starting or running - cancelRedirect(); - // ADFA-4919: no "Run in background" while a proot module runs — the index is the gate. - // ADFA-5074: and not before the run's shape is known either. prootActive() reads a latch - // that only engages once the proot work registers — launched, batched, or reported by the - // queue — so on entry it is false even for a run that is about to install a module, and - // the screen offered an escape it was going to withdraw. Waiting for servicesReady costs - // nothing: until the engine answers there is no live work to leave running anyway, and - // the header already says "Starting services…". - // K2GO-423: the seed is a live, backgroundable step (server already up, so prootActive is - // false); offer Run in background during it even when the module flow never set servicesReady. - show(runBgBtn, (servicesReady || forgejoSeedActive()) && !prootActive); - show(finishBtn, false); show(finishNote, false); show(redirect, false); show(cancel, false); + // K2GO-434: apply the bottom-controls mode from SetupUiState. The Run-in-background visibility + // rule (only once the run's shape is known and no proot module gates the index: ADFA-4919/5074, + // plus the K2GO-423 live seed) is carried by runInBackgroundVisible, computed with the state above. + switch (ui.controls) { + case REDIRECT: + show(redirect, true); show(cancel, true); + show(finishBtn, false); show(finishNote, false); show(runBgBtn, false); + scheduleRedirect(); + break; + case FINISH_SUCCESS: // success, but the user cancelled the countdown + 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 RUNNING: + default: + cancelRedirect(); + show(runBgBtn, ui.runInBackgroundVisible); + show(finishBtn, false); show(finishNote, false); show(redirect, false); show(cancel, false); + break; } } @@ -908,6 +902,17 @@ && getLifecycle().getCurrentState().isAtLeast(androidx.lifecycle.Lifecycle.State * InstallProgressRepository. While running the screen is the gate (no Run in background, Back is * softened then backgrounds the app); on SUCCESS it redirects to a live Library; on FAILED it * shows Finish + the note (never a silent success on a half-rebuilt server). */ + /** K2GO-434: map the semantic status message (SetupUiState) to its string resource. */ + private int statusStringRes(SetupUiState.StatusMessage m) { + switch (m) { + case SLOW: return R.string.k2go_setup_slow; + case STARTING: return R.string.k2go_setup_starting; + case INSTALLING: return R.string.install_busy_modules; + case ADDING: + default: return R.string.k2go_setup_adding; + } + } + private void renderRebuild() { InstallState st = InstallProgressRepository.get().current(); if (st.isRunning()) rebuildRunningSeen = true; diff --git a/controller/app/src/main/java/org/appdevforall/k2go/setup/domain/RunScope.java b/controller/app/src/main/java/org/appdevforall/k2go/setup/domain/RunScope.java new file mode 100644 index 00000000..d4648a65 --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/setup/domain/RunScope.java @@ -0,0 +1,34 @@ +/* + * ============================================================================ + * Name : RunScope.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : K2GO-434. "What work belongs to this run" as a small monotonic latch set, extracted + * from SetupProgressActivity. Each stage (maps, module batch, Forgejo seed, dashboard + * rebuild) latches true the first time a signal for it is seen and STAYS true for the + * run, so a reopened setup screen still renders the stage and reaches completion. The + * Activity computes each per-tick signal from its live sources (that read is + * Android-coupled and stays there); this holds only the latched state. Pure JVM, no + * Android. Slice 2 of controller/docs/ADR-434-setupprogress-decomposition.md. + * ============================================================================ + */ +package org.appdevforall.k2go.setup.domain; + +public final class RunScope { + private boolean maps; + private boolean module; + private boolean forgejoSeed; + private boolean rebuild; + + /** OR the signal into the latch and return the (possibly newly) latched value. */ + public boolean latchMaps(boolean signal) { return maps |= signal; } + public boolean latchModule(boolean signal) { return module |= signal; } + public boolean latchForgejoSeed(boolean signal) { return forgejoSeed |= signal; } + public boolean latchRebuild(boolean signal) { return rebuild |= signal; } + + /** Read the current latch without changing it (for a short-circuit before computing a signal). */ + public boolean maps() { return maps; } + public boolean module() { return module; } + public boolean forgejoSeed() { return forgejoSeed; } + public boolean rebuild() { return rebuild; } +} diff --git a/controller/app/src/main/java/org/appdevforall/k2go/setup/domain/RunSnapshot.java b/controller/app/src/main/java/org/appdevforall/k2go/setup/domain/RunSnapshot.java new file mode 100644 index 00000000..8d627e72 --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/setup/domain/RunSnapshot.java @@ -0,0 +1,81 @@ +/* + * ============================================================================ + * Name : RunSnapshot.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : K2GO-434. The inputs the run verdict rule needs, captured as a plain value (no + * Android, no services). SetupProgressActivity.render() gathers these from its + * repositories/services each pass; RunVerdict turns them into working/success/failure. + * Built with the nested Builder because there are many independent boolean inputs and + * positional arguments would be easy to transpose. Slice 1 of + * controller/docs/ADR-434-setupprogress-decomposition.md. + * ============================================================================ + */ +package org.appdevforall.k2go.setup.domain; + +public final class RunSnapshot { + /** No live REST content in this run (a proot-only set). Selects the completion rule. */ + public final boolean noRest; + /** A proot stage (maps or a module batch) is part of this run. */ + public final boolean prootShown; + /** A non-maps module batch is part of this run. */ + public final boolean moduleShown; + /** The REST pipeline has started every stage it was going to start. */ + public final boolean drained; + /** The proot queue is terminal and not running. */ + public final boolean queueTerminalNotRunning; + /** The post-batch server restart settled: the server came up, or the wait gave up. */ + public final boolean moduleServerSettled; + /** The post-batch server did not come up within the wait: a failure, even with no failed item. */ + public final boolean batchServerSlow; + /** A Forgejo seed still needs to run (or is running) in this run, and can make progress. */ + public final boolean seedPendingRun; + public final StreamState zim; + public final StreamState books; + public final StreamState kolibri; + /** Failed proot runroles that belong to this run. */ + public final int prootFailed; + /** The Forgejo seed of this run gave up. */ + public final boolean forgejoSeedFailed; + + private RunSnapshot(Builder b) { + this.noRest = b.noRest; + this.prootShown = b.prootShown; + this.moduleShown = b.moduleShown; + this.drained = b.drained; + this.queueTerminalNotRunning = b.queueTerminalNotRunning; + this.moduleServerSettled = b.moduleServerSettled; + this.batchServerSlow = b.batchServerSlow; + this.seedPendingRun = b.seedPendingRun; + this.zim = b.zim; + this.books = b.books; + this.kolibri = b.kolibri; + this.prootFailed = b.prootFailed; + this.forgejoSeedFailed = b.forgejoSeedFailed; + } + + public static final class Builder { + private boolean noRest, prootShown, moduleShown, drained; + private boolean queueTerminalNotRunning, moduleServerSettled, batchServerSlow, seedPendingRun; + private StreamState zim = new StreamState(false, false, 0); + private StreamState books = new StreamState(false, false, 0); + private StreamState kolibri = new StreamState(false, false, 0); + private int prootFailed; + private boolean forgejoSeedFailed; + + public Builder noRest(boolean v) { this.noRest = v; return this; } + public Builder prootShown(boolean v) { this.prootShown = v; return this; } + public Builder moduleShown(boolean v) { this.moduleShown = v; return this; } + public Builder drained(boolean v) { this.drained = v; return this; } + public Builder queueTerminalNotRunning(boolean v) { this.queueTerminalNotRunning = v; return this; } + public Builder moduleServerSettled(boolean v) { this.moduleServerSettled = v; return this; } + public Builder batchServerSlow(boolean v) { this.batchServerSlow = v; return this; } + public Builder seedPendingRun(boolean v) { this.seedPendingRun = v; return this; } + public Builder zim(StreamState v) { this.zim = v; return this; } + public Builder books(StreamState v) { this.books = v; return this; } + public Builder kolibri(StreamState v) { this.kolibri = v; return this; } + public Builder prootFailed(int v) { this.prootFailed = v; return this; } + public Builder forgejoSeedFailed(boolean v) { this.forgejoSeedFailed = v; return this; } + public RunSnapshot build() { return new RunSnapshot(this); } + } +} diff --git a/controller/app/src/main/java/org/appdevforall/k2go/setup/domain/RunVerdict.java b/controller/app/src/main/java/org/appdevforall/k2go/setup/domain/RunVerdict.java new file mode 100644 index 00000000..5efe8144 --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/setup/domain/RunVerdict.java @@ -0,0 +1,62 @@ +/* + * ============================================================================ + * Name : RunVerdict.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : K2GO-434. The run verdict rule, extracted from SetupProgressActivity.render() so it + * is one named, unit-tested place instead of inline boolean algebra. Pure: it reads + * only a RunSnapshot. Slice 1 of + * controller/docs/ADR-434-setupprogress-decomposition.md. + * + * Two completion rules by run shape: a proot-only run (no live REST content) finishes + * when its queue is terminal and, for a module batch, the server has settled; a run + * with REST content also waits for every live stream to drain. A pending Forgejo seed + * blocks completion in both. Once complete, the run is a success only with zero + * failures and no slow server restart. + * ============================================================================ + */ +package org.appdevforall.k2go.setup.domain; + +public final class RunVerdict { + + public enum State { WORKING, SUCCESS, FAILURE } + + private final boolean allComplete; + private final int failedTotal; + private final State state; + + private RunVerdict(boolean allComplete, int failedTotal, State state) { + this.allComplete = allComplete; + this.failedTotal = failedTotal; + this.state = state; + } + + public static RunVerdict of(RunSnapshot s) { + boolean allComplete; + if (s.noRest && s.prootShown) { + allComplete = s.queueTerminalNotRunning + && (!s.moduleShown || s.moduleServerSettled) + && !s.seedPendingRun; + } else { + allComplete = s.drained + && s.zim.settledForCompletion() + && s.books.settledForCompletion() + && s.kolibri.settledForCompletion() + && (!s.moduleShown || s.moduleServerSettled) + && !s.seedPendingRun; + } + int failedTotal = s.zim.failed + s.books.failed + s.kolibri.failed + + s.prootFailed + (s.forgejoSeedFailed ? 1 : 0); + State state; + if (allComplete && failedTotal == 0 && !s.batchServerSlow) state = State.SUCCESS; + else if (allComplete && (failedTotal > 0 || s.batchServerSlow)) state = State.FAILURE; + else state = State.WORKING; + return new RunVerdict(allComplete, failedTotal, state); + } + + public boolean allComplete() { return allComplete; } + public int failedTotal() { return failedTotal; } + public boolean success() { return state == State.SUCCESS; } + public boolean failure() { return state == State.FAILURE; } + public State state() { return state; } +} diff --git a/controller/app/src/main/java/org/appdevforall/k2go/setup/domain/SetupUiState.java b/controller/app/src/main/java/org/appdevforall/k2go/setup/domain/SetupUiState.java new file mode 100644 index 00000000..8a183cac --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/setup/domain/SetupUiState.java @@ -0,0 +1,85 @@ +/* + * ============================================================================ + * Name : SetupUiState.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : K2GO-434. The setup screen's derived view state as a pure rule: the status dot tone, + * the status message, whether to animate the waiting ellipsis, the bottom-controls mode + * and whether "Run in background" shows. Semantic enums only (no Android resource ids), + * so the mapping to R.color/R.string and to show()/schedule/cancel stays in the Activity + * and this stays unit-testable on a plain JVM. Slice 3 of + * controller/docs/ADR-434-setupprogress-decomposition.md. + * ============================================================================ + */ +package org.appdevforall.k2go.setup.domain; + +public final class SetupUiState { + + /** The status dot tone: a calm run vs a run that is waiting/working. */ + public enum StatusTone { NEUTRAL, WAITING } + + /** The status line message (mapped to a string resource by the Activity). */ + public enum StatusMessage { STARTING, SLOW, ADDING, INSTALLING } + + /** The bottom-controls mode (mapped to show()/scheduleRedirect()/cancelRedirect() by the Activity). */ + public enum Controls { REDIRECT, FINISH_SUCCESS, FINISH_FAILURE, RUNNING } + + public final StatusTone tone; + public final StatusMessage message; + public final boolean animate; + public final Controls controls; + public final boolean runInBackgroundVisible; + + private SetupUiState(StatusTone tone, StatusMessage message, boolean animate, + Controls controls, boolean runInBackgroundVisible) { + this.tone = tone; + this.message = message; + this.animate = animate; + this.controls = controls; + this.runInBackgroundVisible = runInBackgroundVisible; + } + + public static SetupUiState from(Inputs in) { + // Amber "working" while a module runs, its post-batch restart is pending, or the batch ended + // with a failed module; never while the server is slow (that is a terminal failure). + boolean amberWaiting = !in.batchServerSlow + && (in.moduleFailed || (in.moduleFlow ? !in.batchServerUp : !in.servicesReady)); + StatusTone tone = (amberWaiting || in.batchServerSlow) ? StatusTone.WAITING : StatusTone.NEUTRAL; + + StatusMessage message; + if (in.batchServerSlow) message = StatusMessage.SLOW; // could not bring services up in time + else if (in.batchServerSettling) message = StatusMessage.STARTING; // reconciler is (re)starting the server + else if (in.moduleFlow && !in.batchServerUp) message = StatusMessage.INSTALLING; // runroles in flight + else if (in.moduleFailed) message = StatusMessage.INSTALLING; // keep the amber install header on a failed batch + else if (in.moduleFlow) message = StatusMessage.ADDING; // module done + server up + else if (!in.servicesReady) message = in.slowByPolls ? StatusMessage.SLOW : StatusMessage.STARTING; + else message = StatusMessage.ADDING; + + Controls controls; + if (in.success && !in.redirectCancelled) controls = Controls.REDIRECT; + else if (in.success) controls = Controls.FINISH_SUCCESS; // success, countdown cancelled by the user + else if (in.failure) controls = Controls.FINISH_FAILURE; + else controls = Controls.RUNNING; + + return new SetupUiState(tone, message, amberWaiting, controls, in.runInBackgroundEnabled); + } + + /** The inputs the rule reads, gathered by the Activity from its live sources. */ + public static final class Inputs { + boolean batchServerSlow, batchServerSettling, batchServerUp, moduleFlow, moduleFailed; + boolean servicesReady, slowByPolls, success, failure, redirectCancelled, runInBackgroundEnabled; + + public Inputs batchServerSlow(boolean v) { this.batchServerSlow = v; return this; } + public Inputs batchServerSettling(boolean v) { this.batchServerSettling = v; return this; } + public Inputs batchServerUp(boolean v) { this.batchServerUp = v; return this; } + public Inputs moduleFlow(boolean v) { this.moduleFlow = v; return this; } + public Inputs moduleFailed(boolean v) { this.moduleFailed = v; return this; } + public Inputs servicesReady(boolean v) { this.servicesReady = v; return this; } + public Inputs slowByPolls(boolean v) { this.slowByPolls = v; return this; } + public Inputs success(boolean v) { this.success = v; return this; } + public Inputs failure(boolean v) { this.failure = v; return this; } + public Inputs redirectCancelled(boolean v) { this.redirectCancelled = v; return this; } + public Inputs runInBackgroundEnabled(boolean v) { this.runInBackgroundEnabled = v; return this; } + public SetupUiState build() { return SetupUiState.from(this); } + } +} diff --git a/controller/app/src/main/java/org/appdevforall/k2go/setup/domain/StreamState.java b/controller/app/src/main/java/org/appdevforall/k2go/setup/domain/StreamState.java new file mode 100644 index 00000000..99540264 --- /dev/null +++ b/controller/app/src/main/java/org/appdevforall/k2go/setup/domain/StreamState.java @@ -0,0 +1,31 @@ +/* + * ============================================================================ + * Name : StreamState.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : K2GO-434. One content stream's state, as the run verdict rule needs it. Pure value, + * no Android, no services. Part of the SetupProgressActivity decomposition + * (controller/docs/ADR-434-setupprogress-decomposition.md), slice 1. + * ============================================================================ + */ +package org.appdevforall.k2go.setup.domain; + +public final class StreamState { + /** A job of this type belongs to this run. */ + public final boolean session; + /** That job has reached its terminal, fully drained. */ + public final boolean complete; + /** Items of this type that failed (0 when there is no session). */ + public final int failed; + + public StreamState(boolean session, boolean complete, int failed) { + this.session = session; + this.complete = complete; + this.failed = failed; + } + + /** No longer blocks completion: not in this run, or finished. */ + public boolean settledForCompletion() { + return !session || complete; + } +} diff --git a/controller/app/src/test/java/org/appdevforall/k2go/setup/domain/RunScopeTest.java b/controller/app/src/test/java/org/appdevforall/k2go/setup/domain/RunScopeTest.java new file mode 100644 index 00000000..825c064c --- /dev/null +++ b/controller/app/src/test/java/org/appdevforall/k2go/setup/domain/RunScopeTest.java @@ -0,0 +1,48 @@ +package org.appdevforall.k2go.setup.domain; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** K2GO-434: the per-run stage latches (slice 2). Pure, so a plain JVM test. */ +public class RunScopeTest { + + @Test + public void latchIsMonotonic() { + RunScope s = new RunScope(); + assertFalse(s.maps()); + assertFalse(s.latchMaps(false)); // no signal yet + assertTrue(s.latchMaps(true)); // a signal latches it + assertTrue(s.latchMaps(false)); // stays latched after the signal goes away + assertTrue(s.maps()); + } + + @Test + public void stagesAreIndependent() { + RunScope s = new RunScope(); + s.latchModule(true); + assertTrue(s.module()); + assertFalse(s.maps()); + assertFalse(s.forgejoSeed()); + assertFalse(s.rebuild()); + } + + @Test + public void eachStageLatchesOnItsOwnSignal() { + RunScope s = new RunScope(); + assertTrue(s.latchMaps(true)); + assertTrue(s.latchModule(true)); + assertTrue(s.latchForgejoSeed(true)); + assertTrue(s.latchRebuild(true)); + assertTrue(s.maps() && s.module() && s.forgejoSeed() && s.rebuild()); + } + + @Test + public void readerDoesNotLatch() { + RunScope s = new RunScope(); + assertFalse(s.rebuild()); // reading never sets it + assertFalse(s.rebuild()); + assertFalse(s.latchRebuild(false)); + } +} diff --git a/controller/app/src/test/java/org/appdevforall/k2go/setup/domain/RunVerdictTest.java b/controller/app/src/test/java/org/appdevforall/k2go/setup/domain/RunVerdictTest.java new file mode 100644 index 00000000..f5cb8a71 --- /dev/null +++ b/controller/app/src/test/java/org/appdevforall/k2go/setup/domain/RunVerdictTest.java @@ -0,0 +1,144 @@ +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 run verdict rule (slice 1). Pure, so a plain JVM test. */ +public class RunVerdictTest { + + private static final StreamState NONE = new StreamState(false, false, 0); + + /** A REST run that has fully drained with every stream settled and no proot/seed work. */ + private static RunSnapshot.Builder restDone() { + return new RunSnapshot.Builder() + .noRest(false).prootShown(false).moduleShown(false).drained(true) + .zim(NONE).books(NONE).kolibri(NONE); + } + + /** A proot-only run whose queue is terminal, no module server wait, no seed. */ + private static RunSnapshot.Builder prootOnly() { + return new RunSnapshot.Builder() + .noRest(true).prootShown(true).queueTerminalNotRunning(true); + } + + @Test + public void restNotDrainedIsWorking() { + RunVerdict v = RunVerdict.of(restDone().drained(false).build()); + assertFalse(v.allComplete()); + assertEquals(RunVerdict.State.WORKING, v.state()); + } + + @Test + public void restDrainedNoFailuresIsSuccess() { + RunVerdict v = RunVerdict.of(restDone().build()); + assertTrue(v.allComplete()); + assertTrue(v.success()); + assertEquals(0, v.failedTotal()); + } + + @Test + public void restIncompleteStreamBlocksCompletion() { + RunVerdict v = RunVerdict.of(restDone() + .zim(new StreamState(true, false, 0)).build()); // in session, not complete + assertFalse(v.allComplete()); + assertEquals(RunVerdict.State.WORKING, v.state()); + } + + @Test + public void restStreamFailureIsFailure() { + RunVerdict v = RunVerdict.of(restDone() + .zim(new StreamState(true, true, 2)).build()); + assertTrue(v.allComplete()); + assertTrue(v.failure()); + assertEquals(2, v.failedTotal()); + } + + @Test + public void pendingSeedBlocksCompletion() { + RunVerdict v = RunVerdict.of(restDone().seedPendingRun(true).build()); + assertFalse(v.allComplete()); + } + + @Test + public void moduleServerNotSettledBlocksCompletion() { + RunVerdict v = RunVerdict.of(restDone() + .moduleShown(true).moduleServerSettled(false).build()); + assertFalse(v.allComplete()); + } + + @Test + public void prootOnlyQueueNotTerminalIsWorking() { + RunVerdict v = RunVerdict.of(prootOnly().queueTerminalNotRunning(false).build()); + assertFalse(v.allComplete()); + } + + @Test + public void prootOnlyTerminalIsSuccess() { + RunVerdict v = RunVerdict.of(prootOnly().build()); + assertTrue(v.success()); + } + + @Test + public void prootOnlyModuleSettledIsSuccess() { + RunVerdict v = RunVerdict.of(prootOnly() + .moduleShown(true).moduleServerSettled(true).build()); + assertTrue(v.success()); + } + + @Test + public void prootOnlyModuleNotSettledIsWorking() { + RunVerdict v = RunVerdict.of(prootOnly() + .moduleShown(true).moduleServerSettled(false).build()); + assertFalse(v.allComplete()); + } + + @Test + public void slowServerRestartIsFailureEvenWithZeroFailedItems() { + // The server never came up in time: complete, but a failure, not a silent success. + RunVerdict v = RunVerdict.of(prootOnly() + .moduleShown(true).moduleServerSettled(true).batchServerSlow(true).build()); + assertTrue(v.allComplete()); + assertEquals(0, v.failedTotal()); + assertTrue(v.failure()); + assertFalse(v.success()); + } + + @Test + public void failedTotalSumsEverySource() { + RunVerdict v = RunVerdict.of(restDone() + .zim(new StreamState(true, true, 1)) + .books(new StreamState(true, true, 2)) + .kolibri(new StreamState(true, true, 0)) + .prootFailed(1) + .forgejoSeedFailed(true) + .build()); + assertEquals(1 + 2 + 0 + 1 + 1, v.failedTotal()); + assertTrue(v.failure()); + } + + @Test + public void noRestWithoutProotUsesRestRule() { + // noRest is true but no proot stage is present: the branch selector must fall to the REST rule. + RunVerdict v = RunVerdict.of(restDone().noRest(true).prootShown(false).build()); + assertTrue(v.success()); + } + + @Test + public void slowServerRestartInRestRunIsFailure() { + RunVerdict v = RunVerdict.of(restDone() + .moduleShown(true).moduleServerSettled(true).batchServerSlow(true).build()); + assertTrue(v.allComplete()); + assertFalse(v.success()); + assertTrue(v.failure()); + } + + @Test + public void streamSettledForCompletion() { + assertTrue(new StreamState(false, false, 0).settledForCompletion()); // no session + assertFalse(new StreamState(true, false, 0).settledForCompletion()); // running + assertTrue(new StreamState(true, true, 0).settledForCompletion()); // done + } +} diff --git a/controller/app/src/test/java/org/appdevforall/k2go/setup/domain/SetupUiStateTest.java b/controller/app/src/test/java/org/appdevforall/k2go/setup/domain/SetupUiStateTest.java new file mode 100644 index 00000000..d9c4ef83 --- /dev/null +++ b/controller/app/src/test/java/org/appdevforall/k2go/setup/domain/SetupUiStateTest.java @@ -0,0 +1,98 @@ +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 setup screen's derived view-state rule (slice 3). Pure, so a plain JVM test. */ +public class SetupUiStateTest { + + /** A calm, running REST run: services up, no module, no completion yet. */ + private static SetupUiState.Inputs running() { + return new SetupUiState.Inputs().servicesReady(true); + } + + @Test + public void servicesUpNonModuleIsNeutralAdding() { + SetupUiState ui = running().build(); + assertEquals(SetupUiState.StatusTone.NEUTRAL, ui.tone); + assertEquals(SetupUiState.StatusMessage.ADDING, ui.message); + assertFalse(ui.animate); + } + + @Test + public void startingBeforeServicesReadyIsWaiting() { + SetupUiState ui = new SetupUiState.Inputs().servicesReady(false).build(); + assertEquals(SetupUiState.StatusTone.WAITING, ui.tone); + assertEquals(SetupUiState.StatusMessage.STARTING, ui.message); + assertTrue(ui.animate); + } + + @Test + public void slowByPollsBeforeReadyShowsSlow() { + SetupUiState ui = new SetupUiState.Inputs().servicesReady(false).slowByPolls(true).build(); + assertEquals(SetupUiState.StatusMessage.SLOW, ui.message); + } + + @Test + public void batchServerSlowIsWaitingSlow() { + SetupUiState ui = new SetupUiState.Inputs() + .moduleFlow(true).batchServerSlow(true).build(); + assertEquals(SetupUiState.StatusTone.WAITING, ui.tone); + assertEquals(SetupUiState.StatusMessage.SLOW, ui.message); + assertFalse(ui.animate); // slow is terminal, not an animated wait + } + + @Test + public void batchServerSettlingShowsStarting() { + SetupUiState ui = new SetupUiState.Inputs() + .moduleFlow(true).batchServerSettling(true).batchServerUp(false).build(); + // moduleFlow && !batchServerUp would read INSTALLING, but batchServerSettling wins (checked first). + assertEquals(SetupUiState.StatusMessage.STARTING, ui.message); + } + + @Test + public void moduleInFlightShowsInstalling() { + SetupUiState ui = new SetupUiState.Inputs().moduleFlow(true).batchServerUp(false).build(); + assertEquals(SetupUiState.StatusMessage.INSTALLING, ui.message); + assertEquals(SetupUiState.StatusTone.WAITING, ui.tone); + } + + @Test + public void failedModuleKeepsInstallingHeader() { + SetupUiState ui = new SetupUiState.Inputs() + .moduleFlow(true).batchServerUp(true).moduleFailed(true).build(); + assertEquals(SetupUiState.StatusMessage.INSTALLING, ui.message); + } + + @Test + public void moduleDoneServerUpShowsAdding() { + SetupUiState ui = new SetupUiState.Inputs() + .moduleFlow(true).batchServerUp(true).moduleFailed(false).build(); + assertEquals(SetupUiState.StatusMessage.ADDING, ui.message); + assertEquals(SetupUiState.StatusTone.NEUTRAL, ui.tone); + } + + @Test + public void successRedirectsUntilCancelled() { + assertEquals(SetupUiState.Controls.REDIRECT, + running().success(true).redirectCancelled(false).build().controls); + assertEquals(SetupUiState.Controls.FINISH_SUCCESS, + running().success(true).redirectCancelled(true).build().controls); + } + + @Test + public void failureShowsFinish() { + assertEquals(SetupUiState.Controls.FINISH_FAILURE, running().failure(true).build().controls); + } + + @Test + public void runningPassesThroughRunInBackground() { + SetupUiState on = running().runInBackgroundEnabled(true).build(); + assertEquals(SetupUiState.Controls.RUNNING, on.controls); + assertTrue(on.runInBackgroundVisible); + assertFalse(running().runInBackgroundEnabled(false).build().runInBackgroundVisible); + } +} diff --git a/controller/docs/ADR-434-setupprogress-decomposition.md b/controller/docs/ADR-434-setupprogress-decomposition.md new file mode 100644 index 00000000..2fc6ca17 --- /dev/null +++ b/controller/docs/ADR-434-setupprogress-decomposition.md @@ -0,0 +1,114 @@ +# ADR-434: Decompose SetupProgressActivity into layered slices + +- Status: Accepted +- Date: 2026-09-30 +- Owner ticket: K2GO-434 +- Relates: ADR-5343 (server-lifecycle reconciler), ADR-5061 (REST vs proot operation model) +- Reference slice: rootfs (org.appdevforall.k2go.rootfs) + +## Context + +`SetupProgressActivity` ("Finishing setup") is a god class of about 1600 lines. It is the screen +shown after an install while the box provisions content. Over many tickets it has absorbed six +separate concerns: + +1. Provisioning orchestration: the `readyPoll` loop plus `orchestrateStep()` and `nothingToStart()` + run a serialized pipeline (proot stages exclusive first, then the REST streams ZIM, Books, + Kolibri, and the Forgejo seed). It drains six provisioners in a fixed order and tracks start + timeouts. It holds about fifteen boolean latch fields. +2. Run-session membership: `mapsInSession` / `moduleInSession` / `forgejoSeedInSession` / + `rebuildInSession` latch "what work belongs to this run" from six sources (ModuleQueueRepository, + the provisioners, ModuleBatch, ForgejoInstallPrefs, InstallProgressRepository, the wishlists). +3. Run verdict: the `allComplete` / `success` / `failure` / `failedTotal` block reads five or more + stream states plus "server up" plus the queue phase. It is a rule, computed inline in `render()`. +4. Server-lifecycle coupling: the class implements `ServerController.Host`, owns a ServerController, + sets desired=UP in `onModuleBatchTerminal()`, and reads ServerStateRepository in + `serverObservedUp()`. This overlaps the reconciler (ADR-5343). It is where the Forgejo seed work + hit a duplicate-truth risk on the question "is the server up". +5. Dashboard-rebuild sub-mode: a separate operation (`rebuildInSession` / `renderRebuild` plus a + rebuild branch in `readyPoll` plus its own environment boot) shares this screen. +6. UI and detail navigation: `render()` plus hand-built row views, the status line, the redirect + countdown, and `openDetail` / `backToIndex` / `configureDetailBar` (the fragment host and the + per-detail Retry/Cancel/Back bar). + +This size and coupling cause two concrete problems. First, the class is a merge hotspot: most +install and content features must touch it. Second, it is where duplicate-state bugs appear, because +a new "is X up / running / installed" fact is easy to add here as one more latch. The Forgejo seed +work (K2GO-417/422/423) is the recent example: the seed consumed "is the server up" and "is forgejo +up", and the safe fix was to keep those facts with their owners (the reconciler and the box service +healer), not to add a flag here. + +## Decision + +Break `SetupProgressActivity` up incrementally (strangler-fig) into the layered structure the +project already uses (Presentation depends on Domain; Domain depends on nothing; Data implements +Domain). No big-bang rewrite, no behavior change per step, and one migrator on this class at a time. + +Target shape: + +- Domain (pure JVM, unit-tested): + - `RunVerdict`: given a snapshot of every stream state plus the server-up flag plus the queue + phase, answer working / success / failure / failed-count. + - `RunScope`: the "what work is in this run" latch rule, fed the durable sources each tick. +- Presentation: + - `SetupProgressViewModel` (hand-wired factory, no DI framework): owns the orchestration loop and + the RunScope, and publishes ONE observable `SetupUiState` (status line, rows, controls, verdict). + - `SetupProgressActivity`: renders `SetupUiState` and forwards user actions (Finish, Cancel, Run + in background, Retry). It no longer computes the verdict, drives the pipeline, or holds the latch + fields. +- Data and other owners: unchanged. The provisioners, the services, and the repositories keep their + jobs. + +### The single-owner rule for "is it up" (the load-bearing guard) + +The server lifecycle stays owned by the reconciler (ADR-5343). The ViewModel OBSERVES +ServerStateRepository for "is the box up"; it never keeps its own copy of that fact. When a module +batch ends and the box must come back, the ViewModel records the intent through the reconciler API +(desired=UP), not by poking Preferences and ServerController inline. Content-service health ("is +forgejo up", "is kiwix up") is owned by the box (the dash-node service healer), not by this screen. + +This is the direct lesson of the Forgejo lifecycle work: when a fact about "is it up" is missing, the +fix is to reach the fact's owner, never to add a flag to this screen. Every slice below must preserve +this: no new "up / running / installed" boolean is introduced in the presentation layer. + +## Slice roadmap + +Each slice is about one PR, ordered by safety and value. Slices 1 and 2 are pure domain (no behavior +change, unit-tested) and set the pattern; slice 3 is the presentation pivot; slice 4 pays the +server-lifecycle duplicate-truth debt and is anchored to the upcoming server-lifecycle work; slices 5 +and 6 carve off orthogonal concerns; slice 7 is an optional deeper simplification. + +1. domain `RunVerdict`: extract the completion and success/failure rule from a `RunSnapshot`. + Safest, zero behavior change, high test value. +2. domain `RunScope`: extract the `*InSession` latch rule. Removes about six fields and four methods + from the Activity. +3. presentation `SetupProgressViewModel`: host the orchestration loop and RunScope; publish one + `SetupUiState`. The Activity thins to render plus action-forward. +4. server-lifecycle guard: the ViewModel observes ServerStateRepository (one source); + `onModuleBatchTerminal` routes desired=UP through the reconciler API. Removes the direct + ServerController ownership on the module path. +5. split the dashboard-rebuild sub-mode into its own controller (or screen). +6. extract the detail host and the per-detail action bar into a `SetupDetailHost`. +7. optional, last: unify the provisioner pipeline (one ordered stage set, not N fixed branches in + `orchestrateStep`). + +## Consequences + +Positive: the verdict and the run-scope rules become unit-testable on a plain JVM; the Activity stops +being a merge hotspot for install and content work; a new content type or a server-lifecycle change +no longer risks adding a duplicate "up" fact; each slice is small and reviewable. + +Cost: more classes and one more wiring factory; the ViewModel introduction (slice 3) is the +higher-risk step because it moves the poll loop and the FragmentManager coupling. The slices land +over several PRs, so the class is in a mixed state between them; the one-migrator rule and small PRs +keep that window short. + +## Alternatives considered + +- Rewrite the screen at once: rejected. It is a merge hotspot and a live install path; a big-bang + change is high risk and against the strangler policy. +- Leave it and only refactor by feature: partially kept. The refactor-by-feature default still + holds, and the slices are anchored to the upcoming server-lifecycle work; this ADR only sequences + the decomposition so the slices do not collide and so the single-owner rule is explicit. +- Introduce a DI framework (Hilt/Dagger) for the wiring: out of scope. Wiring is by hand per feature, + as elsewhere; a DI framework is a separate ADR.