summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-11-06 09:08:14 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-11-06 09:08:14 +0000
commit816118d8860093f3c9726b45c41c34c4a2751a2a (patch)
tree7daa61fad974695cd2bc727892601a14514cd032
parentba3ca37897c12d2d285f48e8a429d47b40773df5 (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
-rw-r--r--app/src/main/java/org/terst/listen/ui/ShowRefreshError.kt39
-rw-r--r--app/src/main/java/org/terst/listen/ui/SubscriptionRow.kt11
-rw-r--r--app/src/test/java/org/terst/listen/ui/ShowRefreshErrorTest.kt75
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))
+ }
+}