diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-11-06 09:08:14 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-11-06 09:08:14 +0000 |
| commit | 816118d8860093f3c9726b45c41c34c4a2751a2a (patch) | |
| tree | 7daa61fad974695cd2bc727892601a14514cd032 | |
| parent | ba3ca37897c12d2d285f48e8a429d47b40773df5 (diff) | |
A feed's failure is only worth saying once it has lasted a day
Peter opened the app to six shows in red, every one of them "Could not reach that feed". All six
answered 200 when checked from the server a minute later.
His guess was an auto-refresh on focus. There isn't one -- the only triggers are the button and a
six-hourly worker -- and that makes it worse rather than better: what he was reading was a stale
report of a connection that dropped during a background refresh hours earlier. The line survives
until that feed next succeeds, and the worker is constrained to unmetered networks, so on mobile
data "next" can be a long time.
The error is still worth storing. A feed that has quietly stopped working is indistinguishable
from one with nothing new, which is the whole reason it is kept rather than logged. But one miss
is noise and a pattern is information, and the difference was already recorded: lastFetchedAtMs
is the last time the feed actually worked. So a failure appears once a feed has gone a day
without succeeding -- about four missed attempts at the current cadence. A dropped connection
clears itself and is never seen; something genuinely broken still goes red, the same day.
No schema change; the timestamp was already there.
Two cases stay loud on purpose. A feed that has *never* succeeded reports immediately, because a
subscription that failed the moment it was added is not a blip and somebody is waiting on it. And
a success timestamp in the future is treated as no success, so a clock that has moved cannot hide
a real failure indefinitely.
Not changed, and worth deciding separately: the periodic refresh is unmetered-only, so on mobile
data the library goes stale with nothing saying so. Adding a refresh on launch is now safe in a
way it was not before -- the transient failures it would produce are exactly what this hides --
but it is a behaviour change nobody asked for.
1052 tests, detekt 0, lint clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
3 files changed, 123 insertions, 2 deletions
diff --git a/app/src/main/java/org/terst/listen/ui/ShowRefreshError.kt b/app/src/main/java/org/terst/listen/ui/ShowRefreshError.kt new file mode 100644 index 0000000..31754d5 --- /dev/null +++ b/app/src/main/java/org/terst/listen/ui/ShowRefreshError.kt @@ -0,0 +1,39 @@ +package org.terst.listen.ui + +import java.util.concurrent.TimeUnit + +/** + * Whether a show's last refresh failure is worth showing, and what it says. + * + * The error itself is worth storing: a feed that has quietly stopped working looks exactly like + * one with nothing new. Showing every failure the moment it happens is a different matter. + * Peter opened the app to six shows in red, all of them reachable when checked a minute later -- + * his connection had dropped during a background refresh hours before, and the line survives + * until that feed next succeeds. Nothing refreshes on launch, so he was reading a stale report + * of a moment that was already over. + * + * One miss is noise; a pattern is information. The difference is already recorded, in the last + * time the feed actually worked. + */ +object ShowRefreshError { + + /** + * How long a feed may go without a successful refresh before its failure is worth saying. + * + * A day, against a six-hourly refresh: about four missed attempts. Long enough that a bad + * commute does not paint the library red, short enough that something genuinely broken is + * reported the same day. + */ + private val GRACE_MS = TimeUnit.DAYS.toMillis(1) + + /** The message to show under a show, or null to stay quiet. */ + fun visible(lastError: String?, lastFetchedAtMs: Long?, nowMs: Long): String? { + val error = lastError ?: return null + // Never succeeded: not a blip. A feed that failed when it was subscribed has given no + // reason to expect better, and somebody is waiting on it right now. + val lastGood = lastFetchedAtMs ?: return error + // A success in the future means the clock moved, not that the feed is healthy. + if (lastGood > nowMs) return error + return error.takeIf { nowMs - lastGood > GRACE_MS } + } +} diff --git a/app/src/main/java/org/terst/listen/ui/SubscriptionRow.kt b/app/src/main/java/org/terst/listen/ui/SubscriptionRow.kt index 49ea000..316b72f 100644 --- a/app/src/main/java/org/terst/listen/ui/SubscriptionRow.kt +++ b/app/src/main/java/org/terst/listen/ui/SubscriptionRow.kt @@ -72,8 +72,15 @@ internal fun SubscriptionRow( if (latest != null && actions != null) LatestEpisodeLine(latest, actions) // A feed that has quietly stopped working is otherwise indistinguishable from one - // with nothing new, which is the whole reason the error is stored rather than logged. - subscription.lastError?.let { + // with nothing new, which is the whole reason the error is stored rather than logged + // -- but only once it has gone a day without working. See ShowRefreshError: a + // dropped connection during one background refresh used to paint the library red + // until each feed happened to succeed again, hours later. + ShowRefreshError.visible( + subscription.lastError, + subscription.lastFetchedAtMs, + System.currentTimeMillis(), + )?.let { Text( "Last refresh failed: $it", style = MaterialTheme.typography.bodySmall, diff --git a/app/src/test/java/org/terst/listen/ui/ShowRefreshErrorTest.kt b/app/src/test/java/org/terst/listen/ui/ShowRefreshErrorTest.kt new file mode 100644 index 0000000..ed9dece --- /dev/null +++ b/app/src/test/java/org/terst/listen/ui/ShowRefreshErrorTest.kt @@ -0,0 +1,75 @@ +package org.terst.listen.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.util.concurrent.TimeUnit + +/** + * When a feed's refresh failure is worth putting on the screen. + * + * Peter opened the app to six shows in red, all saying "Could not reach that feed". All six feeds + * answered 200 when checked a minute later: his phone's connection had dropped during a + * background refresh hours earlier, and the line persists until that feed next succeeds. Nothing + * refreshes on launch, so what he was looking at was a stale report of a moment long past. + * + * The line is worth keeping -- a feed that has quietly stopped working is otherwise + * indistinguishable from one with nothing new, which is why the error is stored at all. But one + * miss is noise and a pattern is information, and the difference is already recorded: + * `lastFetchedAtMs` is the last time this feed actually worked. + * + * So a failure is shown once the feed has not succeeded for a day. The periodic refresh runs + * every six hours, so that is about four missed attempts -- a blip clears itself, and something + * genuinely broken still goes red and stays red. + */ +class ShowRefreshErrorTest { + + private val now = 1_800_000_000_000L + private val hour = TimeUnit.HOURS.toMillis(1) + + @Test + fun `no error means nothing to say`() { + assertNull(ShowRefreshError.visible(null, lastFetchedAtMs = now - 100 * hour, nowMs = now)) + } + + /** The case that put six red lines on the screen: one bad moment, fine an hour ago. */ + @Test + fun `a failure since a recent success is not shown`() { + assertNull(ShowRefreshError.visible("Could not reach that feed.", now - 3 * hour, now)) + } + + @Test + fun `a feed that has not worked for a day is shown`() { + assertEquals( + "Could not reach that feed.", + ShowRefreshError.visible("Could not reach that feed.", now - 25 * hour, now), + ) + } + + /** + * Never fetched at all is not a blip. A feed that failed the moment it was subscribed has + * given no reason to think it will work, and the listener is right there waiting to hear. + */ + @Test + fun `a feed that has never succeeded is shown at once`() { + assertEquals( + "That address is not a podcast feed.", + ShowRefreshError.visible("That address is not a podcast feed.", lastFetchedAtMs = null, nowMs = now), + ) + } + + /** A clock that has gone backwards must not hide a real failure for ever. */ + @Test + fun `a success timestamp in the future is treated as no success`() { + assertEquals( + "Could not reach that feed.", + ShowRefreshError.visible("Could not reach that feed.", now + 100 * hour, now), + ) + } + + @Test + fun `the boundary is a day`() { + assertNull(ShowRefreshError.visible("x", now - 23 * hour, now)) + assertEquals("x", ShowRefreshError.visible("x", now - 24 * hour - 1, now)) + } +} |
