diff --git a/controller/app/src/main/java/org/appdevforall/k2go/dashboard/domain/RebuildProgress.java b/controller/app/src/main/java/org/appdevforall/k2go/dashboard/domain/RebuildProgress.java index c84316ed0..eb8ad3e55 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/dashboard/domain/RebuildProgress.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/dashboard/domain/RebuildProgress.java @@ -87,4 +87,22 @@ public static int percentFor(RebuildPhase phase, long elapsedInPhaseMs) { int filled = Math.min(width - 1, (int) Math.floor(width * fraction)); return phase.startPercent + filled; } + + /** + * K2GO-383: a rough ETA to reach 100, in seconds, from the static model: the time left in the + * current phase (its median minus how long we have been in it, floored at 0) plus the full median + * of every later phase. It is an estimate, like the bar itself, not a live fact. {@link + * RebuildPhase#NONE} (no marker yet) returns -1 so the caller can hide the ETA until it is known. + */ + public static long etaSecondsFor(RebuildPhase phase, long elapsedInPhaseMs) { + if (phase == null || phase == RebuildPhase.NONE) { + return -1L; + } + long remainingMs = Math.max(0L, phase.medianMs - Math.max(0L, elapsedInPhaseMs)); + RebuildPhase[] phases = RebuildPhase.values(); + for (int i = phase.ordinal() + 1; i < phases.length; i++) { + remainingMs += phases[i].medianMs; + } + return (remainingMs + 999L) / 1000L; // ceil to whole seconds + } } diff --git a/controller/app/src/main/java/org/appdevforall/k2go/install/presentation/EtaText.java b/controller/app/src/main/java/org/appdevforall/k2go/install/presentation/EtaText.java index 68a4ffca5..dd776a62f 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/install/presentation/EtaText.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/install/presentation/EtaText.java @@ -23,4 +23,13 @@ public static String of(Context ctx, long etaSeconds) { default: return ""; // UNKNOWN -> blank slot } } + + /** K2GO-383: the one-line progress caption, "NN%" or "NN% ~N min left" (the eta is appended only when + * known). {@code percent} must be >= 0; the caller owns the indeterminate (percent < 0) case. Used + * by the rebuild notification, which needs the percent and eta in a single string; the in-app card + * renders them as two columns instead, built from {@link #of} plus its own "NN%". */ + public static String percentAndEta(Context ctx, int percent, long etaSeconds) { + String eta = of(ctx, etaSeconds); + return eta.isEmpty() ? percent + "%" : percent + "% " + eta; + } } diff --git a/controller/app/src/main/java/org/appdevforall/k2go/redesign/DashboardDetailFragment.java b/controller/app/src/main/java/org/appdevforall/k2go/redesign/DashboardDetailFragment.java index db51d7daa..3a6339a77 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/redesign/DashboardDetailFragment.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/redesign/DashboardDetailFragment.java @@ -36,8 +36,7 @@ import org.appdevforall.k2go.R; import org.appdevforall.k2go.dashboard.domain.DashboardCardState; -import org.appdevforall.k2go.dashboard.domain.RebuildPhase; -import org.appdevforall.k2go.dashboard.domain.RebuildProgress; +import org.appdevforall.k2go.install.presentation.EtaText; import org.appdevforall.k2go.util.AppExecutors; public class DashboardDetailFragment extends Fragment { @@ -55,16 +54,16 @@ public class DashboardDetailFragment extends Fragment { // ADFA-5339 / K2GO-374: expandable Details — the live rebuild log via the shared LiveLogPanel. The // toggle stays hidden until there are lines (an older box without /rebuild/log shows no Details). private org.appdevforall.k2go.widget.LiveLogPanel logPanel; - private static final long LOG_POLL_MS = 1500L; - private final Runnable logPoll = this::pollLog; - // K2GO-95 (Phase 2): the in-progress bar is determinate, driven by RebuildProgress from the polled - // log. We keep the current phase and when it began (a monotonic clock — the silent native-build - // stretch carries no log timestamp) so the bar interpolates within a phase and the next real marker - // snaps it forward. Indeterminate only until the first marker (NONE); the snap to 100 is the - // service's completion broadcast, not this poll. + // K2GO-95 / K2GO-383: the determinate in-progress bar + its "NN% ~N min left" caption. The values + // come from DashboardRebuildService (the persistent owner, which outlives this card's recreation) via + // its ACTION_PROGRESS broadcast, so the bar resumes where the rebuild actually is after minimize / + // restore instead of restarting from 0. private LinearProgressIndicator progressBar; - private RebuildPhase progressPhase = RebuildPhase.NONE; - private long progressPhaseStartMs; + // K2GO-383: the status line under the label is two equal-weight columns, the percent and the ETA each + // centered in its own half. Separate columns, so a width change in one never reaches the other. Style + // matches the content updaters' status line (BodySmall, muted, single line). + private TextView updatingPercent; + private TextView updatingEta; /** ADFA-5333: the live update runs in the background (DashboardRebuildService), which broadcasts each * state change. While this card is on screen we show/hide an in-progress bar and, on done, refresh @@ -73,6 +72,15 @@ public class DashboardDetailFragment extends Fragment { private final BroadcastReceiver rebuildState = new BroadcastReceiver() { @Override public void onReceive(Context c, Intent i) { if (!isAdded()) return; + if (DashboardRebuildService.ACTION_PROGRESS.equals(i.getAction())) { + onProgress(i.getIntExtra(DashboardRebuildService.EXTRA_PERCENT, -1), + i.getLongExtra(DashboardRebuildService.EXTRA_ETA_SECONDS, -1L)); + // K2GO-383: the same tick carries the log tail; feed the Details panel from it instead of + // a second /rebuild/log poll. reveal + auto-scroll are handled inside setContent. + String log = i.getStringExtra(DashboardRebuildService.EXTRA_LOG); + if (updating && logPanel != null && log != null) logPanel.setContent(log); + return; + } String state = i.getStringExtra(DashboardRebuildService.EXTRA_STATE); if (DashboardRebuildService.STATE_RUNNING.equals(state)) { setUpdating(true); @@ -142,6 +150,7 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c public void onStart() { super.onStart(); IntentFilter f = new IntentFilter(DashboardRebuildService.ACTION_STATE); + f.addAction(DashboardRebuildService.ACTION_PROGRESS); // K2GO-383: per-tick percent + eta ContextCompat.registerReceiver(requireContext(), rebuildState, f, ContextCompat.RECEIVER_NOT_EXPORTED); resolveInitialUpdatingState(); @@ -277,6 +286,36 @@ private View buildUpdatingRow(Button rebuildBtn) { label.setTextColor(ContextCompat.getColor(requireContext(), R.color.k2go_muted)); row.addView(label); + // K2GO-383: the status line is two equal columns (weight 1 each), the percent and the ETA each + // centered in its own half. Separate columns, so a width change in one never reaches the other; + // centering only reflows the value slightly around its own column center. Same BodySmall / muted / + // single-line style as the content updaters' status line. + LinearLayout statusRow = new LinearLayout(requireContext()); + statusRow.setOrientation(LinearLayout.HORIZONTAL); + LinearLayout.LayoutParams statusLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + statusLp.topMargin = Math.round(2 * d); + statusRow.setLayoutParams(statusLp); + + updatingPercent = new TextView(requireContext()); + updatingPercent.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall); + updatingPercent.setTextColor(ContextCompat.getColor(requireContext(), R.color.k2go_muted)); + updatingPercent.setMaxLines(1); + updatingPercent.setGravity(android.view.Gravity.CENTER); + statusRow.addView(updatingPercent, new LinearLayout.LayoutParams( + 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + + updatingEta = new TextView(requireContext()); + updatingEta.setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall); + updatingEta.setTextColor(ContextCompat.getColor(requireContext(), R.color.k2go_muted)); + updatingEta.setMaxLines(1); + updatingEta.setEllipsize(android.text.TextUtils.TruncateAt.END); + updatingEta.setGravity(android.view.Gravity.CENTER); + statusRow.addView(updatingEta, new LinearLayout.LayoutParams( + 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + + row.addView(statusRow); + // The bar and Cancel sit on one line: bar takes the width, Cancel is right beside it. LinearLayout line = new LinearLayout(requireContext()); line.setOrientation(LinearLayout.HORIZONTAL); @@ -351,61 +390,37 @@ private void setUpdating(boolean on) { if (updatingCancel != null) updatingCancel.setEnabled(on); // re-enable when a new update shows if (rebuild != null) { rebuild.setEnabled(!on); rebuild.setAlpha(on ? 0.5f : 1f); } if (on && rebuildHint != null) rebuildHint.setVisibility(View.GONE); - // ADFA-5339: the Details log only exists while a rebuild runs. Poll it on, tear it down on off. - main.removeCallbacks(logPoll); if (on) { - // K2GO-95: a fresh run starts indeterminate until the first marker; pollLog then drives it. - progressPhase = RebuildPhase.NONE; + // K2GO-383: start indeterminate with the plain label; the service's ACTION_PROGRESS then + // drives the bar, the "NN% ~N min left" caption, and (same tick) the Details panel. if (progressBar != null) progressBar.setIndeterminate(true); + if (updatingPercent != null) updatingPercent.setText(""); // filled by the first progress tick + if (updatingEta != null) updatingEta.setText(""); if (logPanel != null) logPanel.reset(); // K2GO-374: start clean; toggle hidden until lines - main.post(logPoll); } // On "off" the whole updatingRow is hidden above, which takes the panel with it. } - /** ADFA-5339: poll the rebuild log tail while updating. Fork B — the toggle appears only once there - * are lines, so an older box without the endpoint (empty) shows no Details affordance. Reschedules - * itself while {@code updating}; setUpdating(false) and onDestroyView remove the callback. */ - private void pollLog() { - if (!isAdded() || !updating) return; - DashboardClient.rebuildLog(new DashboardClient.RebuildLogCb() { - @Override public void onLines(java.util.List lines) { - if (!isAdded() || !updating) return; - String log = android.text.TextUtils.join("\n", lines); - updateProgressBar(log); - if (logPanel != null) logPanel.setContent(log); // K2GO-374: reveal + auto-scroll handled here - main.postDelayed(logPoll, LOG_POLL_MS); - } - @Override public void onErr(String message) { - // No endpoint / transient: keep the panel as-is (toggle hidden if never populated) and retry. - if (isAdded() && updating) main.postDelayed(logPoll, LOG_POLL_MS); - } - }); - } - - /** K2GO-95 (Phase 2): drive the determinate bar from the polled log. The phase comes from the log's - * markers ({@link RebuildProgress#phaseOf}); time within a phase comes from the monotonic clock kept - * here, so the silent native-build stretch (no log timestamp) still advances. Indeterminate until the - * first marker; the final snap to 100 is the service's completion broadcast, not this poll. */ - private void updateProgressBar(String log) { - if (progressBar == null) return; - RebuildPhase phase = RebuildProgress.phaseOf(log); - if (phase != progressPhase) { - progressPhase = phase; - progressPhaseStartMs = android.os.SystemClock.elapsedRealtime(); - } - if (phase == RebuildPhase.NONE) { + /** K2GO-383: apply a progress tick from DashboardRebuildService (the persistent owner, so the value + * survives this card's recreation). A negative percent means "no phase marker yet" -> indeterminate; + * otherwise set the determinate bar and the two status columns: "NN%" left, the ETA right (eta via + * the shared, already-localized EtaText). */ + private void onProgress(int percent, long etaSeconds) { + if (!updating || progressBar == null) return; + if (percent < 0) { if (!progressBar.isIndeterminate()) progressBar.setIndeterminate(true); + if (updatingPercent != null) updatingPercent.setText(""); // no marker yet; the label stands alone + if (updatingEta != null) updatingEta.setText(""); return; } - long elapsed = android.os.SystemClock.elapsedRealtime() - progressPhaseStartMs; - int pct = RebuildProgress.percentFor(phase, elapsed); if (progressBar.isIndeterminate()) progressBar.setIndeterminate(false); - progressBar.setProgressCompat(pct, true); // animated determinate step + progressBar.setProgressCompat(percent, true); // animated determinate step + if (updatingPercent != null) updatingPercent.setText(percent + "%"); + if (updatingEta != null) updatingEta.setText(EtaText.of(requireContext(), etaSeconds)); } @Override public void onDestroyView() { - main.removeCallbacks(logPoll); // ADFA-5339: never poll past the view's life + main.removeCallbacksAndMessages(null); // ADFA-5339: drop any pending posts past the view's life super.onDestroyView(); } diff --git a/controller/app/src/main/java/org/appdevforall/k2go/redesign/DashboardRebuildService.java b/controller/app/src/main/java/org/appdevforall/k2go/redesign/DashboardRebuildService.java index 8bfb8fc48..95b99cf8e 100644 --- a/controller/app/src/main/java/org/appdevforall/k2go/redesign/DashboardRebuildService.java +++ b/controller/app/src/main/java/org/appdevforall/k2go/redesign/DashboardRebuildService.java @@ -44,6 +44,9 @@ import androidx.core.content.ContextCompat; import org.appdevforall.k2go.R; +import org.appdevforall.k2go.dashboard.domain.RebuildPhase; +import org.appdevforall.k2go.dashboard.domain.RebuildProgress; +import org.appdevforall.k2go.install.presentation.EtaText; public final class DashboardRebuildService extends Service { @@ -64,6 +67,15 @@ public final class DashboardRebuildService extends Service { public static final String STATE_DONE = "done"; public static final String STATE_ERROR = "error"; public static final String STATE_CANCELLED = "cancelled"; + /** K2GO-383: per-tick progress for a visible card. Its own action, so the state receiver is + * untouched. {@link #EXTRA_PERCENT} is 0-100 (or -1 while indeterminate); {@link #EXTRA_ETA_SECONDS} + * is seconds to 100 (or -1 when unknown). */ + public static final String ACTION_PROGRESS = "org.iiab.controller.DASHBOARD_UPDATE_PROGRESS"; + public static final String EXTRA_PERCENT = "percent"; + public static final String EXTRA_ETA_SECONDS = "eta_seconds"; + /** K2GO-383: the rebuild log tail, carried on the same tick so a visible card feeds its Details panel + * from this one poll instead of running a second /rebuild/log loop of its own. */ + public static final String EXTRA_LOG = "log"; private static final long POLL_MS = 2500L; /** @@ -91,6 +103,15 @@ public final class DashboardRebuildService extends Service { private boolean cancelling = false; // a cancel request is in flight; ignore repeats private long startedAtMs = 0L; // ADFA-5343 (Phase 3B): monotonic start, for the stall backstop + // K2GO-383: the persistent owner of rebuild progress. This service outlives the card's recreation + // (minimize / reopen from the notification), so the current phase and when it began live HERE on a + // continuous clock: not in the fragment, where they reset to 0 on every recreation. percent + eta + // are derived each poll and pushed to both the notification and a visible card. + private RebuildPhase progressPhase = RebuildPhase.NONE; + private long phaseStartMs = 0L; + private int lastPercent = -1; + private long lastEtaSeconds = -1L; + /** Kick a NEW background live update (POST + poll). ADFA-5339: {@code updateSite} also refreshes the * served landing page in the same run (default on, from the confirm dialog's checkbox). */ public static void start(Context ctx, boolean updateSite) { @@ -186,12 +207,47 @@ private void pollStatus() { @Override public void onState(String state) { if (STATE_DONE.equals(state)) { finish(STATE_DONE, R.string.k2go_dash_live_done); return; } if (STATE_ERROR.equals(state)) { finish(STATE_ERROR, R.string.k2go_dash_live_error); return; } - schedule(); + pollProgressThenSchedule(); // running: refresh percent/eta from the log, then re-poll } @Override public void onErr(String message) { schedule(); } // restarting mid-swap; keep waiting }); } + /** K2GO-383: fetch the rebuild log, derive percent + eta, push them to the notification and a visible + * card, then schedule the next poll. A missing or transient log keeps the current notification and + * keeps polling (the status poll above owns the terminal verdict). */ + private void pollProgressThenSchedule() { + DashboardClient.rebuildLog(new DashboardClient.RebuildLogCb() { + @Override public void onLines(java.util.List lines) { + publishProgress(android.text.TextUtils.join("\n", lines)); + schedule(); + } + @Override public void onErr(String message) { schedule(); } + }); + } + + /** Derive the phase from the log and the percent/eta from the continuous phase clock kept here (so it + * survives the card's recreation), update the ongoing notification, and broadcast for a visible card. */ + private void publishProgress(String log) { + RebuildPhase phase = RebuildProgress.phaseOf(log); + if (phase != progressPhase) { + progressPhase = phase; + phaseStartMs = android.os.SystemClock.elapsedRealtime(); + } + if (phase == RebuildPhase.NONE) { + lastPercent = -1; lastEtaSeconds = -1L; // no marker yet -> indeterminate + } else { + long elapsed = android.os.SystemClock.elapsedRealtime() - phaseStartMs; + lastPercent = RebuildProgress.percentFor(phase, elapsed); + lastEtaSeconds = RebuildProgress.etaSecondsFor(phase, elapsed); + } + NotificationManager m = getSystemService(NotificationManager.class); + if (m != null) m.notify(NOTIFICATION_ID, buildOngoing()); + sendBroadcast(new Intent(ACTION_PROGRESS).setPackage(getPackageName()) + .putExtra(EXTRA_PERCENT, lastPercent).putExtra(EXTRA_ETA_SECONDS, lastEtaSeconds) + .putExtra(EXTRA_LOG, log)); // same poll feeds the card's Details panel + } + private void schedule() { // ADFA-5343 (Phase 3B): the stall backstop. A rebuild that never reaches terminal within // REBUILD_STALL_MS is failed here so RUNNING clears, the lock drops, and the reconciler resumes @@ -251,17 +307,27 @@ private Notification buildOngoing() { new Intent(this, DashboardCancelConfirmActivity.class) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP), PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); - return new NotificationCompat.Builder(this, CHANNEL_ID) + NotificationCompat.Builder b = new NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle(getString(R.string.k2go_dash_live_title)) - .setContentText(getString(R.string.k2go_dash_live_running)) + .setContentText(progressText()) .setSmallIcon(android.R.drawable.stat_sys_download) .setContentIntent(openDashboardDetail()) .setOngoing(true) .setAutoCancel(false) .setPriority(NotificationCompat.PRIORITY_LOW) .setOnlyAlertOnce(true) - .addAction(0, getString(R.string.k2go_dash_cancel), cancel) - .build(); + .addAction(0, getString(R.string.k2go_dash_cancel), cancel); + // K2GO-383: determinate once the first phase marker is seen; indeterminate until then. + if (lastPercent >= 0) b.setProgress(100, lastPercent, false); + else b.setProgress(0, 0, true); + return b.build(); + } + + /** K2GO-383: the ongoing notification text. "NN% ~N min left" once a phase is known (eta reuses the + * install bar's EtaText, already localized), else the plain running label. */ + private String progressText() { + if (lastPercent < 0) return getString(R.string.k2go_dash_live_running); + return EtaText.percentAndEta(this, lastPercent, lastEtaSeconds); } /** Final result notification (done/error) — dismissible, auto-cancels on tap. */ diff --git a/controller/app/src/test/java/org/appdevforall/k2go/dashboard/domain/RebuildProgressTest.java b/controller/app/src/test/java/org/appdevforall/k2go/dashboard/domain/RebuildProgressTest.java index dedbf9b57..9a4c5e993 100644 --- a/controller/app/src/test/java/org/appdevforall/k2go/dashboard/domain/RebuildProgressTest.java +++ b/controller/app/src/test/java/org/appdevforall/k2go/dashboard/domain/RebuildProgressTest.java @@ -103,4 +103,29 @@ public class RebuildProgressTest { assertTrue("finalize must stay < 100 (status=done snaps to 100), got " + max, max < 100); assertTrue("finalize should climb high, got " + max, max >= 90); } + + // K2GO-383: ETA (seconds to 100), from the static per-phase medians. + @Test public void etaIsUnknownForNoPhase() { + assertEquals(-1L, RebuildProgress.etaSecondsFor(RebuildPhase.NONE, 0)); + assertEquals(-1L, RebuildProgress.etaSecondsFor(null, 0)); + } + + @Test public void etaAtGitStartCoversTheWholeRun() { + // git 2 + staging 146 + smoke 3 + promote 2 + restart 8 + verify 2 + finalize 19 = 182s. + assertEquals(182L, RebuildProgress.etaSecondsFor(RebuildPhase.GIT, 0)); + } + + @Test public void etaShrinksWithinAPhaseAndFloorsAtZero() { + long atStart = RebuildProgress.etaSecondsFor(RebuildPhase.FINALIZE, 0); + long midway = RebuildProgress.etaSecondsFor(RebuildPhase.FINALIZE, 10_000); + assertEquals(19L, atStart); + assertTrue("ETA should drop as the phase runs: " + midway + " < " + atStart, midway < atStart); + assertEquals(0L, RebuildProgress.etaSecondsFor(RebuildPhase.FINALIZE, 19_000)); + assertEquals(0L, RebuildProgress.etaSecondsFor(RebuildPhase.FINALIZE, 100_000)); + } + + @Test public void etaDropsAsPhasesAdvance() { + assertTrue(RebuildProgress.etaSecondsFor(RebuildPhase.STAGING, 0) + > RebuildProgress.etaSecondsFor(RebuildPhase.FINALIZE, 0)); + } }