From b584bc7d2804875d9bd25e8a841c581bd760e488 Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Wed, 9 Sep 2026 10:52:08 +0200 Subject: [PATCH 1/5] Post one progress update at a time instead of one per engine refresh setProgressLines posted a new Runnable for every engine refresh. Each one walks the layout, measures every line and parses it with Html.fromHtml. The engine queued frames faster than the main thread drew them. The queue grew without limit, which is the ANR, and the frames waiting in it are the OutOfMemoryError. Play attributes 7 of 11 ANR clusters and 2 crash clusters to setProgressLinesInternal. Keep the newest lines in a ProgressCoalescer and post one reusable task. A refresh arriving while a task is pending replaces the payload rather than adding a task. That loses nothing, because each frame replaces the whole progress pane, so a frame that is already superseded has nothing left to show. The task takes the payload and disarms in one synchronized step, before it draws. A refresh landing mid-draw therefore finds the coalescer idle and posts a task of its own. So the last frame of a crawl is drawn even when it arrives while its predecessor is still on screen. Handler.post is also checked, because a false return on a dead looper would leave the latch armed and freeze progress for good. Html.fromHtml still runs per line per frame. Coalescing bounds how often, and moving the parse off the main thread is a separate change. Closes #187 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Xavier Roche --- .../com/httrack/android/HTTrackActivity.java | 23 ++- .../httrack/android/ProgressCoalescer.java | 42 +++++ .../android/ProgressCoalescerTest.java | 146 ++++++++++++++++++ 3 files changed, 205 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/httrack/android/ProgressCoalescer.java create mode 100644 app/src/test/java/com/httrack/android/ProgressCoalescerTest.java diff --git a/app/src/main/java/com/httrack/android/HTTrackActivity.java b/app/src/main/java/com/httrack/android/HTTrackActivity.java index c92d610..090c3a8 100755 --- a/app/src/main/java/com/httrack/android/HTTrackActivity.java +++ b/app/src/main/java/com/httrack/android/HTTrackActivity.java @@ -226,6 +226,19 @@ protected static class VERSION_CODES { // Handler to execute code in UI thread private final Handler handlerUI = new Handler(); + // The engine refreshes faster than the main thread can draw, so only the newest frame is queued + private final ProgressCoalescer progressLines = new ProgressCoalescer(); + + private final Runnable progressLinesTask = new Runnable() { + @Override + public void run() { + final String[] lines = progressLines.take(); + if (lines != null) { + setProgressLinesInternal(lines); + } + } + }; + // Interrupt was requested protected boolean interruptRequested; @@ -2380,12 +2393,10 @@ private void removeLinesFromLayout(final LinearLayout layout, * Set the "progress" layout lines. To be run in any thread. */ protected void setProgressLines(final String[] lines) { - handlerUI.post(new Runnable() { - @Override - public void run() { - setProgressLinesInternal(lines); - } - }); + if (progressLines.offer(lines) && !handlerUI.post(progressLinesTask)) { + // The looper is gone, so nothing would ever disarm the coalescer. + progressLines.take(); + } } /* diff --git a/app/src/main/java/com/httrack/android/ProgressCoalescer.java b/app/src/main/java/com/httrack/android/ProgressCoalescer.java new file mode 100644 index 0000000..b973856 --- /dev/null +++ b/app/src/main/java/com/httrack/android/ProgressCoalescer.java @@ -0,0 +1,42 @@ +package com.httrack.android; + +/** + * At most one pending UI update, always carrying the newest payload. One posted task per engine + * refresh grows the main thread's queue until the app stops answering input, and the frames dropped + * in between cost nothing, because each one replaces the whole progress pane anyway. + */ +final class ProgressCoalescer { + private T pending; + private boolean armed; + + /** + * Keep this payload as the one to draw next. + * + * @param payload the newest progress, never null + * @return true when the caller must schedule the drawing task + */ + synchronized boolean offer(final T payload) { + if (payload == null) { + throw new NullPointerException("payload"); + } + pending = payload; + if (armed) { + return false; + } + armed = true; + return true; + } + + /** + * Take the payload to draw, and let the next offer schedule again. Disarming here rather than + * after the drawing gives a refresh that lands mid-draw a task of its own. + * + * @return the newest payload, or null when nothing is pending + */ + synchronized T take() { + final T payload = pending; + pending = null; + armed = false; + return payload; + } +} diff --git a/app/src/test/java/com/httrack/android/ProgressCoalescerTest.java b/app/src/test/java/com/httrack/android/ProgressCoalescerTest.java new file mode 100644 index 0000000..068dfea --- /dev/null +++ b/app/src/test/java/com/httrack/android/ProgressCoalescerTest.java @@ -0,0 +1,146 @@ +package com.httrack.android; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Test; + +/** + * The queue discipline HTTrackActivity.setProgressLines follows, with a counter standing in for + * the looper no unit test can run. + */ +public class ProgressCoalescerTest { + /** Posting, and drawing split in two, so a refresh can be timed to land mid-draw. */ + private static final class FakeUiThread { + final ProgressCoalescer coalescer = new ProgressCoalescer(); + final List drawn = new ArrayList(); + int queued; + + void refresh(final String payload) { + if (coalescer.offer(payload)) { + queued++; + } + } + + String startDraw() { + assertTrue("no task was posted", queued > 0); + queued--; + return coalescer.take(); + } + + void endDraw(final String payload) { + if (payload != null) { + drawn.add(payload); + } + } + + void runOneTask() { + endDraw(startDraw()); + } + + void drain() { + while (queued > 0) { + runOneTask(); + } + } + } + + @Test + public void aBurstOfRefreshesPostsOneTaskCarryingTheLastOne() { + final FakeUiThread ui = new FakeUiThread(); + for (final String frame : new String[] { "1", "2", "3", "4", "5" }) { + ui.refresh(frame); + } + assertEquals(1, ui.queued); + ui.drain(); + assertEquals(Arrays.asList("5"), ui.drawn); + } + + @Test + public void aTaskThatHasRunLetsTheNextRefreshPostAgain() { + final FakeUiThread ui = new FakeUiThread(); + final List expected = new ArrayList(); + for (int i = 0; i < 100; i++) { + final String frame = "frame " + i; + ui.refresh(frame); + assertEquals(1, ui.queued); + ui.drain(); + expected.add(frame); + } + assertEquals(expected, ui.drawn); + } + + @Test + public void aRefreshArrivingMidDrawGetsATaskOfItsOwn() { + final FakeUiThread ui = new FakeUiThread(); + ui.refresh("first"); + final String drawing = ui.startDraw(); + ui.refresh("last"); + ui.endDraw(drawing); + ui.drain(); + assertEquals(Arrays.asList("first", "last"), ui.drawn); + } + + /** Whatever the engine and the looper do, the frame nothing follows must reach the screen. */ + @Test + public void theLastFrameIsDrawnWhateverTheInterleaving() { + for (int seed = 0; seed < 64; seed++) { + final FakeUiThread ui = new FakeUiThread(); + int schedule = seed; + for (int frame = 0; frame < 20; frame++) { + ui.refresh("frame " + frame); + if (schedule % 2 == 0 && ui.queued > 0) { + ui.runOneTask(); + } + schedule /= 2; + } + ui.drain(); + assertEquals("seed " + seed, "frame 19", ui.drawn.get(ui.drawn.size() - 1)); + } + } + + @Test + public void aTaskWhoseFrameWasAlreadyDrawnDrawsNothing() { + final ProgressCoalescer coalescer = new ProgressCoalescer(); + assertNull(coalescer.take()); + coalescer.offer("frame"); + assertEquals("frame", coalescer.take()); + assertNull(coalescer.take()); + } + + @Test + public void theCrawlThreadAndTheUiThreadAgreeOnTheLastFrame() throws InterruptedException { + final int frames = 5000; + final ProgressCoalescer coalescer = new ProgressCoalescer(); + final AtomicInteger queued = new AtomicInteger(); + final Thread crawl = new Thread(new Runnable() { + @Override + public void run() { + for (int frame = 1; frame <= frames; frame++) { + if (coalescer.offer(Integer.valueOf(frame))) { + queued.incrementAndGet(); + } + } + } + }); + crawl.start(); + + Integer last = null; + while (crawl.isAlive() || queued.get() > 0) { + while (queued.get() > 0) { + queued.decrementAndGet(); + final Integer payload = coalescer.take(); + if (payload != null) { + last = payload; + } + } + } + crawl.join(); + assertEquals(Integer.valueOf(frames), last); + } +} From 6c1623d8cc4d7581f457bbc44caf2708caf186ca Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Wed, 9 Sep 2026 11:06:01 +0200 Subject: [PATCH 2/5] Say disarm at the call site that only wants the latch cleared The failed-post branch called take() and dropped the payload. take() promises a payload, so a reader had to work out that dropping it is safe. The payload dropped need not even be the one that caller offered. A refresh landing between the offer and the post's false return replaces it first. disarm() says what the branch means, and take() now uses it. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Xavier Roche --- .../java/com/httrack/android/HTTrackActivity.java | 2 +- .../java/com/httrack/android/ProgressCoalescer.java | 7 ++++++- .../com/httrack/android/ProgressCoalescerTest.java | 11 +++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/httrack/android/HTTrackActivity.java b/app/src/main/java/com/httrack/android/HTTrackActivity.java index 090c3a8..e84ec81 100755 --- a/app/src/main/java/com/httrack/android/HTTrackActivity.java +++ b/app/src/main/java/com/httrack/android/HTTrackActivity.java @@ -2395,7 +2395,7 @@ private void removeLinesFromLayout(final LinearLayout layout, protected void setProgressLines(final String[] lines) { if (progressLines.offer(lines) && !handlerUI.post(progressLinesTask)) { // The looper is gone, so nothing would ever disarm the coalescer. - progressLines.take(); + progressLines.disarm(); } } diff --git a/app/src/main/java/com/httrack/android/ProgressCoalescer.java b/app/src/main/java/com/httrack/android/ProgressCoalescer.java index b973856..271181b 100644 --- a/app/src/main/java/com/httrack/android/ProgressCoalescer.java +++ b/app/src/main/java/com/httrack/android/ProgressCoalescer.java @@ -35,8 +35,13 @@ synchronized boolean offer(final T payload) { */ synchronized T take() { final T payload = pending; + disarm(); + return payload; + } + + /** Drop whatever is pending and let the next offer schedule again. */ + synchronized void disarm() { pending = null; armed = false; - return payload; } } diff --git a/app/src/test/java/com/httrack/android/ProgressCoalescerTest.java b/app/src/test/java/com/httrack/android/ProgressCoalescerTest.java index 068dfea..889e6a1 100644 --- a/app/src/test/java/com/httrack/android/ProgressCoalescerTest.java +++ b/app/src/test/java/com/httrack/android/ProgressCoalescerTest.java @@ -113,6 +113,17 @@ public void aTaskWhoseFrameWasAlreadyDrawnDrawsNothing() { assertNull(coalescer.take()); } + /** What the failed-post branch of setProgressLines rests on. */ + @Test + public void disarmingDropsThePendingFrameAndLetsTheNextOnePost() { + final ProgressCoalescer coalescer = new ProgressCoalescer(); + assertTrue(coalescer.offer("stranded")); + coalescer.disarm(); + assertNull(coalescer.take()); + assertTrue(coalescer.offer("next")); + assertEquals("next", coalescer.take()); + } + @Test public void theCrawlThreadAndTheUiThreadAgreeOnTheLastFrame() throws InterruptedException { final int frames = 5000; From a4fec40f935690d64b8ca6925c854961f399938e Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Wed, 9 Sep 2026 11:09:27 +0200 Subject: [PATCH 3/5] Cover the refused null frame, and name what the fake UI thread counts The drawing task reads an empty coalescer as a null payload, so a null frame would be drawn as nothing. Nothing tested the refusal. The rest is shape. The counter is postedTasks, not queued, because the name has to say whose queue it stands for. The schedule count and the frame count are named, and "frame " + i has a helper. startDraw() no longer hides an assertion inside the fake, and the last test drives the fake instead of hand-rolling a second copy of it. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Xavier Roche --- .../android/ProgressCoalescerTest.java | 91 +++++++++++-------- 1 file changed, 53 insertions(+), 38 deletions(-) diff --git a/app/src/test/java/com/httrack/android/ProgressCoalescerTest.java b/app/src/test/java/com/httrack/android/ProgressCoalescerTest.java index 889e6a1..c8c3dda 100644 --- a/app/src/test/java/com/httrack/android/ProgressCoalescerTest.java +++ b/app/src/test/java/com/httrack/android/ProgressCoalescerTest.java @@ -15,21 +15,37 @@ * the looper no unit test can run. */ public class ProgressCoalescerTest { + /** Every drain pattern over the first six frames of a run. */ + private static final int SCHEDULES = 64; + private static final int FRAMES_PER_SCHEDULE = 20; + private static final int CONCURRENT_FRAMES = 5000; + + private static String frame(final int index) { + return "frame " + index; + } + /** Posting, and drawing split in two, so a refresh can be timed to land mid-draw. */ private static final class FakeUiThread { final ProgressCoalescer coalescer = new ProgressCoalescer(); + + /** What the drawing task has put on screen, oldest first. */ final List drawn = new ArrayList(); - int queued; + + /** Tasks posted and not yet run, counted atomically because the crawl thread offers too. */ + private final AtomicInteger postedTasks = new AtomicInteger(); void refresh(final String payload) { if (coalescer.offer(payload)) { - queued++; + postedTasks.incrementAndGet(); } } + int postedTasks() { + return postedTasks.get(); + } + String startDraw() { - assertTrue("no task was posted", queued > 0); - queued--; + postedTasks.decrementAndGet(); return coalescer.take(); } @@ -44,21 +60,25 @@ void runOneTask() { } void drain() { - while (queued > 0) { + while (postedTasks() > 0) { runOneTask(); } } + + String lastDrawn() { + return drawn.get(drawn.size() - 1); + } } @Test public void aBurstOfRefreshesPostsOneTaskCarryingTheLastOne() { final FakeUiThread ui = new FakeUiThread(); - for (final String frame : new String[] { "1", "2", "3", "4", "5" }) { - ui.refresh(frame); + for (int i = 0; i < 5; i++) { + ui.refresh(frame(i)); } - assertEquals(1, ui.queued); + assertEquals(1, ui.postedTasks()); ui.drain(); - assertEquals(Arrays.asList("5"), ui.drawn); + assertEquals(Arrays.asList(frame(4)), ui.drawn); } @Test @@ -66,11 +86,10 @@ public void aTaskThatHasRunLetsTheNextRefreshPostAgain() { final FakeUiThread ui = new FakeUiThread(); final List expected = new ArrayList(); for (int i = 0; i < 100; i++) { - final String frame = "frame " + i; - ui.refresh(frame); - assertEquals(1, ui.queued); + ui.refresh(frame(i)); + assertEquals(1, ui.postedTasks()); ui.drain(); - expected.add(frame); + expected.add(frame(i)); } assertEquals(expected, ui.drawn); } @@ -81,6 +100,7 @@ public void aRefreshArrivingMidDrawGetsATaskOfItsOwn() { ui.refresh("first"); final String drawing = ui.startDraw(); ui.refresh("last"); + assertEquals(1, ui.postedTasks()); ui.endDraw(drawing); ui.drain(); assertEquals(Arrays.asList("first", "last"), ui.drawn); @@ -89,18 +109,18 @@ public void aRefreshArrivingMidDrawGetsATaskOfItsOwn() { /** Whatever the engine and the looper do, the frame nothing follows must reach the screen. */ @Test public void theLastFrameIsDrawnWhateverTheInterleaving() { - for (int seed = 0; seed < 64; seed++) { + for (int schedule = 0; schedule < SCHEDULES; schedule++) { final FakeUiThread ui = new FakeUiThread(); - int schedule = seed; - for (int frame = 0; frame < 20; frame++) { - ui.refresh("frame " + frame); - if (schedule % 2 == 0 && ui.queued > 0) { + int drainAfter = schedule; + for (int i = 0; i < FRAMES_PER_SCHEDULE; i++) { + ui.refresh(frame(i)); + if (drainAfter % 2 == 0) { ui.runOneTask(); } - schedule /= 2; + drainAfter /= 2; } ui.drain(); - assertEquals("seed " + seed, "frame 19", ui.drawn.get(ui.drawn.size() - 1)); + assertEquals("schedule " + schedule, frame(FRAMES_PER_SCHEDULE - 1), ui.lastDrawn()); } } @@ -124,34 +144,29 @@ public void disarmingDropsThePendingFrameAndLetsTheNextOnePost() { assertEquals("next", coalescer.take()); } + /** The drawing task tells an empty coalescer apart by the null, so no frame may be one. */ + @Test(expected = NullPointerException.class) + public void aNullFrameIsRefused() { + new ProgressCoalescer().offer(null); + } + @Test public void theCrawlThreadAndTheUiThreadAgreeOnTheLastFrame() throws InterruptedException { - final int frames = 5000; - final ProgressCoalescer coalescer = new ProgressCoalescer(); - final AtomicInteger queued = new AtomicInteger(); + final FakeUiThread ui = new FakeUiThread(); final Thread crawl = new Thread(new Runnable() { @Override public void run() { - for (int frame = 1; frame <= frames; frame++) { - if (coalescer.offer(Integer.valueOf(frame))) { - queued.incrementAndGet(); - } + for (int i = 0; i < CONCURRENT_FRAMES; i++) { + ui.refresh(frame(i)); } } }); crawl.start(); - - Integer last = null; - while (crawl.isAlive() || queued.get() > 0) { - while (queued.get() > 0) { - queued.decrementAndGet(); - final Integer payload = coalescer.take(); - if (payload != null) { - last = payload; - } - } + while (crawl.isAlive()) { + ui.drain(); } crawl.join(); - assertEquals(Integer.valueOf(frames), last); + ui.drain(); + assertEquals(frame(CONCURRENT_FRAMES - 1), ui.lastDrawn()); } } From 65ef712f9ae63a3a6cf658aa2b9a340217ac0c53 Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Wed, 9 Sep 2026 11:20:49 +0200 Subject: [PATCH 4/5] Make pending the whole state, and say which way offer's boolean runs armed was true exactly when pending was non-null, so two fields carried one state and only a reading of the code held them in agreement. A probe over every sequence of offer, take and disarm up to length seven found they never disagree. So pending == null now means no task is owed. The null payload that offer refuses stops being a dead guard under that change. A null would read as nothing pending and let a second task be posted over the first, and the javadoc says so. offer returning true meant "you owe a post", which reads backwards against Queue.offer, where true means accepted. offerNeedsPost says which way it runs at the call site. Two elements stay defensive, and each comment now says why it cannot fire. post() refuses only once the Looper quits, and take() answers null only after a disarm that follows a post. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Xavier Roche --- .../com/httrack/android/HTTrackActivity.java | 5 +++-- .../httrack/android/ProgressCoalescer.java | 21 ++++++++----------- .../android/ProgressCoalescerTest.java | 12 +++++------ 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/httrack/android/HTTrackActivity.java b/app/src/main/java/com/httrack/android/HTTrackActivity.java index e84ec81..057e1af 100755 --- a/app/src/main/java/com/httrack/android/HTTrackActivity.java +++ b/app/src/main/java/com/httrack/android/HTTrackActivity.java @@ -233,6 +233,7 @@ protected static class VERSION_CODES { @Override public void run() { final String[] lines = progressLines.take(); + // Defensive: only a disarm after the post could empty the coalescer, and nothing live does. if (lines != null) { setProgressLinesInternal(lines); } @@ -2393,8 +2394,8 @@ private void removeLinesFromLayout(final LinearLayout layout, * Set the "progress" layout lines. To be run in any thread. */ protected void setProgressLines(final String[] lines) { - if (progressLines.offer(lines) && !handlerUI.post(progressLinesTask)) { - // The looper is gone, so nothing would ever disarm the coalescer. + if (progressLines.offerNeedsPost(lines) && !handlerUI.post(progressLinesTask)) { + // Unreachable short of process death, since post() only refuses once the Looper quits. progressLines.disarm(); } } diff --git a/app/src/main/java/com/httrack/android/ProgressCoalescer.java b/app/src/main/java/com/httrack/android/ProgressCoalescer.java index 271181b..e947276 100644 --- a/app/src/main/java/com/httrack/android/ProgressCoalescer.java +++ b/app/src/main/java/com/httrack/android/ProgressCoalescer.java @@ -6,29 +6,27 @@ * in between cost nothing, because each one replaces the whole progress pane anyway. */ final class ProgressCoalescer { + /** The frame to draw next, and null exactly when no task has been posted to draw one. */ private T pending; - private boolean armed; /** - * Keep this payload as the one to draw next. + * Keep this payload as the one to draw next, and say whether a task must now be posted. * - * @param payload the newest progress, never null - * @return true when the caller must schedule the drawing task + * @param payload the newest progress, never null, since a null would read as nothing pending + * @return true when the caller must schedule the drawing task, false when a pending one will + * carry this payload instead */ - synchronized boolean offer(final T payload) { + synchronized boolean offerNeedsPost(final T payload) { if (payload == null) { throw new NullPointerException("payload"); } + final boolean needsPost = pending == null; pending = payload; - if (armed) { - return false; - } - armed = true; - return true; + return needsPost; } /** - * Take the payload to draw, and let the next offer schedule again. Disarming here rather than + * Take the payload to draw, and let the next offer schedule again. Clearing here rather than * after the drawing gives a refresh that lands mid-draw a task of its own. * * @return the newest payload, or null when nothing is pending @@ -42,6 +40,5 @@ synchronized T take() { /** Drop whatever is pending and let the next offer schedule again. */ synchronized void disarm() { pending = null; - armed = false; } } diff --git a/app/src/test/java/com/httrack/android/ProgressCoalescerTest.java b/app/src/test/java/com/httrack/android/ProgressCoalescerTest.java index c8c3dda..f36cda4 100644 --- a/app/src/test/java/com/httrack/android/ProgressCoalescerTest.java +++ b/app/src/test/java/com/httrack/android/ProgressCoalescerTest.java @@ -35,7 +35,7 @@ private static final class FakeUiThread { private final AtomicInteger postedTasks = new AtomicInteger(); void refresh(final String payload) { - if (coalescer.offer(payload)) { + if (coalescer.offerNeedsPost(payload)) { postedTasks.incrementAndGet(); } } @@ -128,7 +128,7 @@ public void theLastFrameIsDrawnWhateverTheInterleaving() { public void aTaskWhoseFrameWasAlreadyDrawnDrawsNothing() { final ProgressCoalescer coalescer = new ProgressCoalescer(); assertNull(coalescer.take()); - coalescer.offer("frame"); + coalescer.offerNeedsPost("frame"); assertEquals("frame", coalescer.take()); assertNull(coalescer.take()); } @@ -137,17 +137,17 @@ public void aTaskWhoseFrameWasAlreadyDrawnDrawsNothing() { @Test public void disarmingDropsThePendingFrameAndLetsTheNextOnePost() { final ProgressCoalescer coalescer = new ProgressCoalescer(); - assertTrue(coalescer.offer("stranded")); + assertTrue(coalescer.offerNeedsPost("stranded")); coalescer.disarm(); assertNull(coalescer.take()); - assertTrue(coalescer.offer("next")); + assertTrue(coalescer.offerNeedsPost("next")); assertEquals("next", coalescer.take()); } - /** The drawing task tells an empty coalescer apart by the null, so no frame may be one. */ + /** A null frame would read as no task pending, and post a second one over the first. */ @Test(expected = NullPointerException.class) public void aNullFrameIsRefused() { - new ProgressCoalescer().offer(null); + new ProgressCoalescer().offerNeedsPost(null); } @Test From 99ac51794b8957e0ece45c93b378c81567cf0a7b Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Wed, 9 Sep 2026 11:22:07 +0200 Subject: [PATCH 5/5] Give the doc-comment summaries a subject and a verb Three summaries opened with a noun phrase, which reads as a javadoc convention and hides that no sentence is there. The class, the field and the test class now start on a verb. The class doc also ran to 34 words in one sentence, so it is two. Take() no longer nests a clause between the two objects of "gives". Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Xavier Roche --- .../java/com/httrack/android/ProgressCoalescer.java | 12 ++++++------ .../com/httrack/android/ProgressCoalescerTest.java | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/httrack/android/ProgressCoalescer.java b/app/src/main/java/com/httrack/android/ProgressCoalescer.java index e947276..6f43b60 100644 --- a/app/src/main/java/com/httrack/android/ProgressCoalescer.java +++ b/app/src/main/java/com/httrack/android/ProgressCoalescer.java @@ -1,12 +1,12 @@ package com.httrack.android; /** - * At most one pending UI update, always carrying the newest payload. One posted task per engine - * refresh grows the main thread's queue until the app stops answering input, and the frames dropped - * in between cost nothing, because each one replaces the whole progress pane anyway. + * Holds at most one pending update, always the newest. One posted task per engine refresh grows + * the main thread's queue until the app stops answering input. The frames dropped in between cost + * nothing, because each one replaces the whole progress pane. */ final class ProgressCoalescer { - /** The frame to draw next, and null exactly when no task has been posted to draw one. */ + /** Holds the frame to draw next, and is null exactly when no task has been posted. */ private T pending; /** @@ -26,8 +26,8 @@ synchronized boolean offerNeedsPost(final T payload) { } /** - * Take the payload to draw, and let the next offer schedule again. Clearing here rather than - * after the drawing gives a refresh that lands mid-draw a task of its own. + * Take the payload to draw, and let the next offer schedule again. Clear before the drawing, not + * after, so a refresh landing mid-draw gets a task of its own. * * @return the newest payload, or null when nothing is pending */ diff --git a/app/src/test/java/com/httrack/android/ProgressCoalescerTest.java b/app/src/test/java/com/httrack/android/ProgressCoalescerTest.java index f36cda4..79120cc 100644 --- a/app/src/test/java/com/httrack/android/ProgressCoalescerTest.java +++ b/app/src/test/java/com/httrack/android/ProgressCoalescerTest.java @@ -11,8 +11,8 @@ import org.junit.Test; /** - * The queue discipline HTTrackActivity.setProgressLines follows, with a counter standing in for - * the looper no unit test can run. + * Verifies the queue discipline setProgressLines follows, with a counter standing in for the + * untestable looper. */ public class ProgressCoalescerTest { /** Every drain pattern over the first six frames of a run. */