summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.coverage-baseline2
-rw-r--r--CLAUDE.md51
-rw-r--r--app/src/main/java/org/terst/listen/data/Daos.kt2
-rw-r--r--app/src/main/java/org/terst/listen/feed/FeedFailureText.kt44
-rw-r--r--app/src/main/java/org/terst/listen/feed/FeedFetcher.kt18
-rw-r--r--app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt46
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt15
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlaybackFailureText.kt45
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlayerConnection.kt3
-rw-r--r--app/src/main/java/org/terst/listen/ui/FailureText.kt30
-rw-r--r--app/src/main/java/org/terst/listen/ui/ListenViewModel.kt7
-rw-r--r--app/src/main/java/org/terst/listen/ui/OpmlViewModel.kt9
-rw-r--r--app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt4
-rw-r--r--app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt23
-rw-r--r--app/src/test/java/org/terst/listen/OutboundRequestsTest.kt111
-rw-r--r--app/src/test/java/org/terst/listen/PlainLanguageTest.kt91
-rw-r--r--app/src/test/java/org/terst/listen/data/FakeDaos.kt3
-rw-r--r--app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt5
-rw-r--r--app/src/test/java/org/terst/listen/feed/FeedArchiveWalkerTest.kt2
-rw-r--r--app/src/test/java/org/terst/listen/feed/FeedFetcherTest.kt20
-rw-r--r--app/src/test/java/org/terst/listen/fingerprint/ShowDetectionStateTest.kt55
-rw-r--r--app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt84
-rw-r--r--app/src/test/java/org/terst/listen/playback/PlaybackAdvancerTest.kt66
-rw-r--r--app/src/test/java/org/terst/listen/playback/PlaybackFailureTextTest.kt80
-rw-r--r--app/src/test/java/org/terst/listen/ui/FakeUiGraph.kt11
-rw-r--r--app/src/test/java/org/terst/listen/ui/ListenViewModelTest.kt3
-rw-r--r--app/src/test/java/org/terst/listen/ui/OpmlViewModelTest.kt15
-rw-r--r--app/src/test/java/org/terst/listen/ui/PlayerViewModelTest.kt11
-rw-r--r--app/src/test/java/org/terst/listen/ui/ShowSettingsViewModelTest.kt64
29 files changed, 716 insertions, 204 deletions
diff --git a/.coverage-baseline b/.coverage-baseline
index e04ecf2..f7407d8 100644
--- a/.coverage-baseline
+++ b/.coverage-baseline
@@ -1 +1 @@
-94.58
+94.12
diff --git a/CLAUDE.md b/CLAUDE.md
index b32083e..df5b251 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -168,14 +168,17 @@ clip files on disk, and unsubscribing is the only thing that clears them.
columns the on-device detector used to, which is why playback needed no changes when detection
moved. No migration, no schema change, nothing for a real library to go wrong over.
-Two things the deletion left standing that a future session should look at rather than assume:
-
-- **The per-show detection readout has nothing feeding it.** `ShowSettingsViewModel.loadDetection`
- reads `ShowFingerprintDao.get` and `MidrollDao.getCandidates`, and nothing has written a
- *candidate* row since detection moved: the server writes per-episode ranges and offsets, not the
- confirmation state the readout describes. So "2 of 3 episodes agree" can never appear, and a
- phone that never ran the old version shows "nothing found yet" for every show while the server
- is finding and publishing breaks. Predates this deletion; it is the same migration's doing.
+Two things the deletion left standing. The first is fixed; the second is not:
+
+- **The per-show detection readout had nothing feeding it — fixed 2026-11-05.** It read
+ `ShowFingerprintDao.get` and `MidrollDao.getCandidates`, the confirmation state the *on-device*
+ detector kept, and nothing had written a candidate row since detection moved. So every show
+ reported "Nothing found yet" for ever, including shows the server was actively publishing breaks
+ for — seen on Peter's phone. It is counted off the episodes now (`detectedIntroMs`,
+ `detectedOutroMs`, and the midroll ranges `ServerSkips` writes), worded by `SkipSummary.showState`,
+ and it keeps the distinction that was the point: *not checked* and *checked, nothing found* are
+ different answers. `show_midrolls` is consequently an entity nothing reads or writes; the table
+ stays because dropping it needs a migration and nothing is gained by one.
- **`RetentionSweeper`'s pending-episode guard protects nothing.** It reads
`getAllPendingEpisodeIds` and `getAllMidrollPendingEpisodeIds` so retention cannot reclaim a file
mid-detection. Nothing sets a pending basis any more, so both are always empty — harmless, but a
@@ -553,6 +556,38 @@ by default and correct; it is simply waiting for a publisher who marks breaks.
## Things a future session should know
+- **Nothing the listener reads is written for a machine, and the build enforces it**
+ (`PlainLanguageTest`, added 2026-11-05). A red banner on Peter's phone read "Playback failed:
+ ERROR_CODE_IO_BAD_HTTP_STATUS"; his words were "arcane and sketchy" and both halves were fair.
+ The same pass found a downloader printing `${it.message}`, an OPML import printing
+ `permission denied`, and `FeedFetcher` putting the literal word `UnknownHostException` under a
+ podcast's name in the library — **and two tests that asserted the machine text came through**,
+ which is how it survived review. Causes go to logcat; the screen gets a sentence, from
+ `PlaybackFailureText`, `FeedFailureText` or `FailureText`. Accuracy is the harder half of the
+ rule: "it will be retried" would read better than "try the download button again" and would be
+ a lie, because a failed download is deliberately not retried.
+
+- **Every outbound request goes through one client, and the build enforces that too**
+ (`OutboundRequestsTest`). One `OkHttpClient`, built in `AppGraph`, carrying
+ `SecureSchemeInterceptor`; everything else is handed it, Coil's artwork fetches included. The
+ same arrangement the server has in `safefetch/audit_test.go`, for the same reason: this app
+ fetches URLs chosen by podcast publishers.
+
+- **Playback plays the app's own copy, never the publisher's** (changed 2026-11-05).
+ `toMediaItem` falls back to the enclosure URL when there is no local file, and that fallback was
+ reachable from every play: pressing play on an undownloaded episode streamed it straight from
+ the publisher's CDN. That is the automatic fallback the download path had removed on purpose; it
+ is an unasked-for request to a third party; and because nothing in playback checks
+ `audioFromServer`, the breaks the server measured on *its* copy were being applied to a copy with
+ different advertising in different places. `PlaybackAdvancer.start` now returns `NeedsFetch` for
+ any episode with no file, so the app fetches first.
+
+ **One caller still reaches the fallback: Android Auto.** `onAddMediaItems` resolves a browse id
+ straight to a media item without going through the advancer, and a car that silently plays
+ nothing is worse than a car that streams. Fixing it properly means fetching inside the session
+ callback. `OutboundRequestsTest` pins the fallback to exactly one line so it cannot spread back.
+
+
- **The episode screen is the player; there is no separate player screen** (merged 2026-10-19).
Opening an episode puts you in its transport at the stored position, and pressing play changes
nothing about the layout. Playback state still has one owner, `PlayerViewModel`; the screen
diff --git a/app/src/main/java/org/terst/listen/data/Daos.kt b/app/src/main/java/org/terst/listen/data/Daos.kt
index da31a88..e03a866 100644
--- a/app/src/main/java/org/terst/listen/data/Daos.kt
+++ b/app/src/main/java/org/terst/listen/data/Daos.kt
@@ -437,8 +437,6 @@ interface ShowFingerprintDao {
*/
@Dao
interface MidrollDao {
- @Query("SELECT * FROM show_midrolls WHERE subscriptionId = :subscriptionId")
- suspend fun getCandidates(subscriptionId: String): List<ShowMidrollEntity>
// Ordered by start: MidrollSkip.skipTarget filters by kind before firstOrNull, so an
diff --git a/app/src/main/java/org/terst/listen/feed/FeedFailureText.kt b/app/src/main/java/org/terst/listen/feed/FeedFailureText.kt
new file mode 100644
index 0000000..becbffe
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/feed/FeedFailureText.kt
@@ -0,0 +1,44 @@
+package org.terst.listen.feed
+
+/**
+ * Why a feed could not be refreshed, in words the listener can act on.
+ *
+ * These are read in two places -- the banner after trying to subscribe, and the error line under
+ * a show in the library -- and until now they were whatever the machine said. A dead host put
+ * the literal word `UnknownHostException` under a podcast's name; a moved feed said
+ * `HTTP 404 Not Found`.
+ *
+ * Accuracy is the constraint, not just plainness. A 404 and a 503 look the same to a library
+ * ("it didn't work") and are not the same thing to the person deciding whether to unsubscribe,
+ * so they say different things. Where the cause genuinely cannot be narrowed, the sentence
+ * stays vague rather than guessing. The exception goes to logcat.
+ */
+object FeedFailureText {
+
+ private const val UNAUTHORISED = 401
+ private const val FORBIDDEN = 403
+ private const val NOT_FOUND = 404
+ private const val GONE = 410
+ private const val SERVER_ERROR = 500
+ private const val LAST_STATUS = 599
+
+ /** A response that arrived and said no. */
+ fun status(code: Int): String = when (code) {
+ NOT_FOUND, GONE -> "That feed is no longer at this address."
+ UNAUTHORISED, FORBIDDEN -> "That feed refused the request."
+ in SERVER_ERROR..LAST_STATUS -> "That feed's server is having trouble. It is worth trying later."
+ else -> "That feed's server could not provide it."
+ }
+
+ /** The request never completed: no DNS, no route, a dropped connection, a timeout. */
+ const val UNREACHABLE = "Could not reach that feed."
+
+ /** The address itself is not usable, which is the one case the listener can fix directly. */
+ const val NOT_AN_ADDRESS = "That does not look like a feed address."
+
+ /** Something arrived, and it was not a feed. */
+ const val UNREADABLE = "That feed could not be read."
+
+ /** It parsed, but as something with no show in it -- a parked domain, a login wall. */
+ const val NOT_A_FEED = "That address is not a podcast feed."
+}
diff --git a/app/src/main/java/org/terst/listen/feed/FeedFetcher.kt b/app/src/main/java/org/terst/listen/feed/FeedFetcher.kt
index 671953a..5790c63 100644
--- a/app/src/main/java/org/terst/listen/feed/FeedFetcher.kt
+++ b/app/src/main/java/org/terst/listen/feed/FeedFetcher.kt
@@ -2,6 +2,7 @@ package org.terst.listen.feed
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
+import android.util.Log
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
@@ -38,20 +39,23 @@ class FeedFetcher(private val client: OkHttpClient) {
when {
response.code == NOT_MODIFIED -> FeedFetchResult.NotModified
!response.isSuccessful ->
- FeedFetchResult.Failed("HTTP ${response.code} ${response.message}".trim())
+ FeedFetchResult.Failed(FeedFailureText.status(response.code))
else -> parse(response.body?.byteStream(), response.header("ETag"),
response.header("Last-Modified"))
}
}
} catch (e: IOException) {
- FeedFetchResult.Failed(e.message ?: e.javaClass.simpleName)
+ // The cause is for the log, not the library screen; see FeedFailureText.
+ Log.w(TAG, "could not reach $url", e)
+ FeedFetchResult.Failed(FeedFailureText.UNREACHABLE)
} catch (e: IllegalArgumentException) {
- FeedFetchResult.Failed("Not a usable feed URL: ${e.message}")
+ Log.w(TAG, "not a usable feed address: $url", e)
+ FeedFetchResult.Failed(FeedFailureText.NOT_AN_ADDRESS)
}
}
private fun parse(stream: java.io.InputStream?, etag: String?, lastModified: String?): FeedFetchResult {
- if (stream == null) return FeedFetchResult.Failed("Empty response")
+ if (stream == null) return FeedFetchResult.Failed(FeedFailureText.UNREADABLE)
// Deliberately broad. This parses arbitrary bytes from an arbitrary server, and a
// refresh sweeps every subscription in one pass -- one publisher serving something
// malformed must degrade to a recorded error on that feed, not take down the batch.
@@ -59,13 +63,14 @@ class FeedFetcher(private val client: OkHttpClient) {
val feed = try {
PodcastFeedParser.parse(stream)
} catch (e: Exception) {
- return FeedFetchResult.Failed("Could not parse feed: ${e.message ?: e.javaClass.simpleName}")
+ Log.w(TAG, "could not parse feed", e)
+ return FeedFetchResult.Failed(FeedFailureText.UNREADABLE)
}
// A 200 carrying a parked domain or a login wall parses as XML with nothing in it. A real
// show that has not published yet still has a title, so requiring one of the two
// distinguishes "no episodes yet" from "this is not a feed".
if (feed.title == null && feed.episodes.isEmpty()) {
- return FeedFetchResult.Failed("Response was not a podcast feed")
+ return FeedFetchResult.Failed(FeedFailureText.NOT_A_FEED)
}
return FeedFetchResult.Fetched(feed, etag, lastModified)
}
@@ -81,6 +86,7 @@ class FeedFetcher(private val client: OkHttpClient) {
* proof-of-origin token or a borrowed login, which Listen still doesn't do. Listen/1.0 stays on
* the end so the traffic is still attributable in a server log.
*/
+ const val TAG = "ListenFeed"
const val USER_AGENT =
"Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36 Listen/1.0"
}
diff --git a/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt b/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt
index 2f0545d..2d4bc49 100644
--- a/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt
+++ b/app/src/main/java/org/terst/listen/fingerprint/SkipSummary.kt
@@ -4,8 +4,6 @@ import org.terst.listen.download.WaitingForServerText
import org.terst.listen.data.DownloadState
import org.terst.listen.data.EpisodeEntity
import org.terst.listen.data.EpisodeMidrollEntity
-import org.terst.listen.data.ShowFingerprintEntity
-import org.terst.listen.data.ShowMidrollEntity
/**
* What the episode screen says about skipping, as three numbers and at most one status.
@@ -145,32 +143,28 @@ object SkipSummary {
*/
fun isAdvertising(kind: String): Boolean = kind !in setOf("intro", "cue", "outro")
- /** One end of a show's episodes -- its intro or its outro. */
- fun candidateState(state: ShowFingerprintEntity?, threshold: Int): String {
- // No reference clip means no candidate: either nothing has been processed, or a single
- // episode's clip is set aside with nothing yet to compare it against. Both are the same
- // thing to a reader -- detection has not found anything.
- if (state?.referenceClipPcm == null) return "Nothing found yet"
-
- return if (state.confirmedCount >= threshold) {
- "Confirmed by ${state.confirmedCount} episodes"
- } else {
- "Found, ${state.confirmedCount} of $threshold episodes agree"
- }
+ /**
+ * What the server has worked out for one show, across the episodes it has looked at.
+ *
+ * Replaces a pair of readouts that could not say anything. They read `show_fingerprints` and
+ * `show_midrolls`, the confirmation state the on-device detector kept, and nothing has
+ * written a row of either since detection moved to the server -- so every show reported
+ * "Nothing found yet" for ever, including shows breaks were actively being published for.
+ *
+ * The distinction that matters is still the one it always was: looked-and-found-nothing is a
+ * different answer from not-looked-yet, and a reader who cannot tell them apart has no idea
+ * whether to wait. [checked] is how many of the show's episodes the server has examined and
+ * [found] how many of those carry this kind of skip.
+ */
+ fun showState(checked: Int, found: Int): String = when {
+ checked <= 0 -> "Not checked yet"
+ found <= 0 -> "None found in $checked ${episodes(checked)}"
+ found >= checked && checked == 1 -> "Found in the 1 episode checked"
+ found >= checked -> "Found in all $checked episodes"
+ else -> "Found in $found of $checked episodes"
}
- /** A show's midroll candidates, which unlike intro/outro can be several at once. */
- fun midrollState(candidates: List<ShowMidrollEntity>, threshold: Int): String {
- if (candidates.isEmpty()) return "None found yet"
-
- val confirmed = candidates.count { it.confirmedCount >= threshold }
- val gathering = candidates.size - confirmed
-
- return listOfNotNull(
- "$confirmed confirmed".takeIf { confirmed > 0 },
- "$gathering still gathering".takeIf { gathering > 0 },
- ).joinToString(", ")
- }
+ private fun episodes(n: Int) = if (n == 1) "episode" else "episodes"
/**
* Everything the episode screen says about skipping, decided here rather than in the
diff --git a/app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt b/app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt
index 9007c51..f40adef 100644
--- a/app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt
+++ b/app/src/main/java/org/terst/listen/playback/PlaybackAdvancer.kt
@@ -179,9 +179,20 @@ class PlaybackAdvancer(
val episode = episodes.getById(episodeId) ?: return StartOutcome.Gone
val show = subscriptions.getById(episode.subscriptionId)
- // Nothing to play until yt-dlp has written a file. The download is the caller's job.
+ // Nothing plays until there is a file. The fetch is the caller's job.
+ //
+ // For YouTube and Facebook that is obvious -- their media URL is a watch page and yt-dlp
+ // has to write the audio first. For an ordinary podcast it is a correctness rule, and it
+ // was missing: `toMediaItem` falls back to `setUri(mediaUrl)`, so pressing play on an
+ // undownloaded episode opened the publisher's URL and streamed it.
+ //
+ // Three things wrong with that. It is the automatic publisher fallback the download path
+ // had removed on purpose. It is an outbound request to a third party that nothing asked
+ // for. And nothing in playback checks `audioFromServer`, so the breaks the server
+ // measured on its own copy were being applied to a copy with different advertising in
+ // different places -- skips landing in the wrong spot, with no way to tell why.
val kind = show?.kind
- if (kind?.needsExtraction == true && episode.localPath == null) {
+ if (episode.localPath == null && kind != null) {
return StartOutcome.NeedsFetch(episode, kind)
}
diff --git a/app/src/main/java/org/terst/listen/playback/PlaybackFailureText.kt b/app/src/main/java/org/terst/listen/playback/PlaybackFailureText.kt
new file mode 100644
index 0000000..f23a043
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/playback/PlaybackFailureText.kt
@@ -0,0 +1,45 @@
+package org.terst.listen.playback
+
+/**
+ * What the listener is told when playback stops, in words rather than in codes.
+ *
+ * The banner used to print Media3's own constant: "Playback failed: ERROR_CODE_IO_BAD_HTTP_STATUS".
+ * That is arcane -- it names an implementation detail of a library the listener does not use --
+ * and it reads as sketchy, because it announces an HTTP request without saying to whom or why.
+ *
+ * Both complaints were fair, and the second was pointing at a real bug rather than a wording
+ * problem: the player was streaming episodes straight from their publishers, so there genuinely
+ * was an unexplained request. See [PlaybackAdvancer.start].
+ *
+ * Each line says what happened and, where there is one, what to do about it. Nothing here
+ * guesses: an unmapped code gets the sentence that is true of all of them. The raw name is
+ * logged, which is where a code belongs.
+ */
+object PlaybackFailureText {
+
+ /** The sentence for one Media3 `errorCodeName`. */
+ fun of(errorCodeName: String): String = when (errorCodeName) {
+ "ERROR_CODE_IO_BAD_HTTP_STATUS" -> "The server refused to send this episode's audio."
+ "ERROR_CODE_IO_FILE_NOT_FOUND" -> "This episode's download is missing. Download it again."
+ "ERROR_CODE_IO_NETWORK_CONNECTION_FAILED",
+ "ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT",
+ -> "No connection, so this episode could not be played."
+ "ERROR_CODE_IO_NO_PERMISSION" -> "Listen was not allowed to open this episode's audio."
+ "ERROR_CODE_DECODING_FAILED",
+ "ERROR_CODE_PARSING_CONTAINER_MALFORMED",
+ "ERROR_CODE_PARSING_MANIFEST_MALFORMED",
+ -> "This episode's audio could not be played; the file may be damaged."
+ // Should no longer be reachable: MediaUrl upgrades a cleartext enclosure, and an episode
+ // is fetched rather than streamed. Kept because "no longer reachable" has been wrong before.
+ "ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED" -> "This episode is only offered over an insecure connection."
+ else -> UNKNOWN
+ }
+
+ /**
+ * Everything true of a failure nobody mapped.
+ *
+ * Deliberately not "Playback failed: <code>". A sentence the listener can read beats a
+ * constant they cannot, and inventing a cause would be worse than either.
+ */
+ const val UNKNOWN = "This episode stopped playing."
+}
diff --git a/app/src/main/java/org/terst/listen/playback/PlayerConnection.kt b/app/src/main/java/org/terst/listen/playback/PlayerConnection.kt
index d994092..95229f9 100644
--- a/app/src/main/java/org/terst/listen/playback/PlayerConnection.kt
+++ b/app/src/main/java/org/terst/listen/playback/PlayerConnection.kt
@@ -2,6 +2,7 @@ package org.terst.listen.playback
import android.content.ComponentName
import android.content.Context
+import android.util.Log
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.session.MediaController
@@ -122,6 +123,8 @@ class PlayerConnection(private val context: Context) : PlaybackControls {
override fun onPlaybackStateChanged(playbackState: Int) = publish()
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
+ // The code goes to the log, where a code is useful; the listener gets a sentence.
+ Log.w("ListenPlayback", "playback failed: ${error.errorCodeName}", error)
onError?.invoke(error.errorCodeName)
publish()
}
diff --git a/app/src/main/java/org/terst/listen/ui/FailureText.kt b/app/src/main/java/org/terst/listen/ui/FailureText.kt
new file mode 100644
index 0000000..2fada1e
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/FailureText.kt
@@ -0,0 +1,30 @@
+package org.terst.listen.ui
+
+/**
+ * What the listener is told when something the app was asked to do did not work.
+ *
+ * Every one of these used to end in `${it.message}` -- the exception's own text, printed to the
+ * screen. That is the same fault as "Playback failed: ERROR_CODE_IO_BAD_HTTP_STATUS": it is
+ * written for whoever wrote the library, it reads as something having gone wrong at a level the
+ * listener cannot reach, and on a bad day it puts a URL or a host name in front of them.
+ *
+ * What replaces it has to be *accurate* as well as plain, which is the harder half. "It will be
+ * retried" would be clearer and would also be a lie: a failed download is left FAILED on purpose
+ * and retry is the download button (see DownloadDrainer). So these say what is true and what the
+ * listener can actually do, and nothing else. The exception goes to logcat.
+ *
+ * [org.terst.listen.playback.PlaybackFailureText] is the same idea for playback, where Media3
+ * gives a code worth mapping. Here there is nothing worth mapping: an IOException from a feed's
+ * CDN says nothing a listener can act on differently.
+ */
+object FailureText {
+
+ /** A download the app tried and could not finish. Retry is deliberately manual. */
+ const val DOWNLOAD = "Could not download that episode. Try the download button again."
+
+ /** Reading a file the listener picked. The file is the thing they can change. */
+ const val OPML_READ = "Could not read that file. It needs to be an OPML subscription list."
+
+ /** Writing one out, where the place they chose is the thing they can change. */
+ const val OPML_WRITE = "Could not save the file. Try somewhere else, or check there is room."
+}
diff --git a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
index 0fe8132..b8df247 100644
--- a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
@@ -2,6 +2,7 @@ package org.terst.listen.ui
import org.terst.listen.download.WaitingForServerException
import android.app.Application
+import android.util.Log
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -242,7 +243,8 @@ class ListenViewModel(app: Application, private val graph: UiGraph) : AndroidVie
_banner.value = if (it is WaitingForServerException) {
Banner(WaitingForServerText.BANNER)
} else {
- Banner("Download failed: ${it.message}", isError = true)
+ Log.w("ListenDownload", "download failed", it)
+ Banner(FailureText.DOWNLOAD, isError = true)
}
}
}
@@ -255,7 +257,8 @@ class ListenViewModel(app: Application, private val graph: UiGraph) : AndroidVie
fun downloadFromPublisher(episode: EpisodeEntity, onDone: () -> Unit = {}) {
viewModelScope.launch {
graph.downloader.downloadFromPublisher(episode).onFailure {
- _banner.value = Banner("Download failed: ${it.message}", isError = true)
+ Log.w("ListenDownload", "publisher download failed", it)
+ _banner.value = Banner(FailureText.DOWNLOAD, isError = true)
}
onDone()
}
diff --git a/app/src/main/java/org/terst/listen/ui/OpmlViewModel.kt b/app/src/main/java/org/terst/listen/ui/OpmlViewModel.kt
index 8443561..53d7f3c 100644
--- a/app/src/main/java/org/terst/listen/ui/OpmlViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/OpmlViewModel.kt
@@ -1,6 +1,7 @@
package org.terst.listen.ui
import android.app.Application
+import android.util.Log
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
@@ -54,7 +55,8 @@ class OpmlViewModel(app: Application, private val graph: UiGraph) : AndroidViewM
val feeds = runCatching {
open()?.use { Opml.parse(it) }.orEmpty()
}.getOrElse {
- _banner.value = Banner("Could not read that file: ${it.message}", isError = true)
+ Log.w("ListenOpml", "import failed", it)
+ _banner.value = Banner(FailureText.OPML_READ, isError = true)
return@launch
}
@@ -92,7 +94,10 @@ class OpmlViewModel(app: Application, private val graph: UiGraph) : AndroidViewM
open()?.use { Opml.write(feeds, it) }
}.fold(
onSuccess = { _banner.value = Banner("Exported ${feeds.size} subscription(s)") },
- onFailure = { _banner.value = Banner("Export failed: ${it.message}", isError = true) },
+ onFailure = {
+ Log.w("ListenOpml", "export failed", it)
+ _banner.value = Banner(FailureText.OPML_WRITE, isError = true)
+ },
)
}
}
diff --git a/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt b/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt
index 7ff96b4..1c42c13 100644
--- a/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt
@@ -12,6 +12,7 @@ import org.terst.listen.data.EpisodeEntity
import org.terst.listen.data.Graph
import org.terst.listen.feed.Chapter
import org.terst.listen.playback.NowPlaying
+import org.terst.listen.playback.PlaybackFailureText
import org.terst.listen.playback.PlaybackSpeed
import org.terst.listen.playback.SleepTimer
import org.terst.listen.data.UiGraph
@@ -53,7 +54,8 @@ class PlayerViewModel(
init {
connection.connect()
- connection.onError = { code -> _banner.value = Banner("Playback failed: $code", isError = true) }
+ // Words, not Media3's constant; the code itself goes to logcat. See PlaybackFailureText.
+ connection.onError = { code -> _banner.value = Banner(PlaybackFailureText.of(code), isError = true) }
// Position advances with no callback of its own, so the bar is driven by a tick.
viewModelScope.launch {
diff --git a/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt b/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt
index 6ed7bf6..6bdf4c4 100644
--- a/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/ShowSettingsViewModel.kt
@@ -8,7 +8,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.terst.listen.data.ArchiveOutcome
-import org.terst.listen.data.FingerprintKind
import org.terst.listen.data.Graph
import org.terst.listen.data.ShowTier
import org.terst.listen.data.UiGraph
@@ -158,19 +157,17 @@ class ShowSettingsViewModel(app: Application, private val graph: UiGraph) : Andr
*/
fun loadDetection(subscriptionId: String) {
viewModelScope.launch {
+ // Counted off the episodes, because that is where the server's answers land.
+ // `detectedIntroMs`, `detectedOutroMs` and the midroll ranges are what ServerSkips
+ // writes; the show-level confirmation rows this used to read have had no writer
+ // since detection moved, so every show read "Nothing found yet" whatever was true.
+ val episodes = graph.episodes.getForSubscription(subscriptionId)
+ val checked = episodes.count { it.audioFromServer }
+ val withRanges = episodes.count { it.audioFromServer && graph.midrolls.getRanges(it.id).isNotEmpty() }
_detection.value = ShowDetection(
- intro = SkipSummary.candidateState(
- graph.fingerprints.get(subscriptionId, FingerprintKind.INTRO),
- SkipSummary.CONFIRM_THRESHOLD,
- ),
- outro = SkipSummary.candidateState(
- graph.fingerprints.get(subscriptionId, FingerprintKind.OUTRO),
- SkipSummary.CONFIRM_THRESHOLD,
- ),
- midrolls = SkipSummary.midrollState(
- graph.midrolls.getCandidates(subscriptionId),
- SkipSummary.CONFIRM_THRESHOLD,
- ),
+ intro = SkipSummary.showState(checked, episodes.count { it.audioFromServer && it.detectedIntroMs != null }),
+ outro = SkipSummary.showState(checked, episodes.count { it.audioFromServer && it.detectedOutroMs != null }),
+ midrolls = SkipSummary.showState(checked, withRanges),
)
}
}
diff --git a/app/src/test/java/org/terst/listen/OutboundRequestsTest.kt b/app/src/test/java/org/terst/listen/OutboundRequestsTest.kt
new file mode 100644
index 0000000..85f179b
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/OutboundRequestsTest.kt
@@ -0,0 +1,111 @@
+package org.terst.listen
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import java.io.File
+import org.junit.Test
+
+/**
+ * Every request this app makes goes out through one client, and nothing else opens a connection.
+ *
+ * Peter asked for this after a banner announced an HTTP failure he had not asked for: "all
+ * communication is whitelisted and invoked only when appropriate". The cause turned out to be
+ * real -- the player was streaming episodes from their publishers whenever one was not
+ * downloaded -- but the general point stands on its own. An app that fetches arbitrary URLs
+ * chosen by podcast publishers should have one way out, not five.
+ *
+ * The server has the same check for the same reason (safefetch/audit_test.go), written after an
+ * SSRF showed up in the third place that fetched a URL somebody else chose.
+ */
+class OutboundRequestsTest {
+
+ private val mainDir = File("src/main/java/org/terst/listen")
+
+ private fun sources() = mainDir.walkTopDown().filter { it.extension == "kt" }.toList()
+
+ private fun linesOf(file: File) = file.readLines()
+ .map { it.substringBefore("//") }
+ .filterNot { it.trimStart().startsWith("*") }
+
+ /**
+ * The one place a client is built. Everything else is handed it, so the interceptors,
+ * timeouts and user agent apply to every request including Coil's artwork fetches.
+ */
+ private val clientHome = "Repositories.kt"
+
+ @Test
+ fun `only one file builds an http client`() {
+ val builders = sources().filter { file ->
+ linesOf(file).any { it.contains("OkHttpClient.Builder()") || it.contains("OkHttpClient(") }
+ }
+
+ assertEquals(
+ "A second client would miss the interceptors the first one carries. Inject " +
+ "AppGraph.client instead. Found: ${builders.map { it.name }}",
+ listOf(clientHome),
+ builders.map { it.name },
+ )
+ }
+
+ /** The client everything shares has to be the one that upgrades cleartext. */
+ @Test
+ fun `the shared client sends everything over TLS`() {
+ val home = sources().single { it.name == clientHome }
+
+ assertTrue(
+ "$clientHome builds the app's only client, so it is the only place that can " +
+ "guarantee TLS; see SecureSchemeInterceptor",
+ home.readText().contains("SecureSchemeInterceptor"),
+ )
+ }
+
+ /**
+ * A connection opened any other way carries none of that: no TLS upgrade, no user agent, no
+ * timeout, and nothing in this test to notice it.
+ */
+ @Test
+ fun `nothing opens a connection behind the client's back`() {
+ val banned = listOf("HttpURLConnection", "openConnection(", "URLConnection", "Socket(")
+ val offenders = mutableListOf<String>()
+ for (file in sources()) {
+ linesOf(file).forEachIndexed { index, line ->
+ banned.filter { line.contains(it) }.forEach {
+ offenders += "${file.name}:${index + 1} uses $it"
+ }
+ }
+ }
+ assertTrue("Route it through AppGraph.client:\n${offenders.joinToString("\n")}", offenders.isEmpty())
+ }
+
+ /**
+ * The app plays its own copy of an episode, not the publisher's.
+ *
+ * `toMediaItem` falls back to the enclosure URL when there is no file, and that fallback was
+ * how an unasked-for request reached a publisher's CDN -- and how the server's measured
+ * breaks came to be applied to a copy with different advertising in it. `PlaybackAdvancer`
+ * now fetches first, so the app's own play paths never reach the fallback.
+ *
+ * It survives for one caller: Android Auto hands back a browse id, `onAddMediaItems`
+ * resolves it straight to a media item, and a car that silently plays nothing is worse than
+ * a car that streams. Fixing that properly means fetching inside the session callback.
+ *
+ * Until then this pins it to one line, so it cannot spread back into the paths it was just
+ * taken out of.
+ */
+ @Test
+ fun `the publisher stream fallback exists in exactly one place`() {
+ val fallbacks = sources().flatMap { file ->
+ linesOf(file).mapIndexedNotNull { index, line ->
+ "${file.name}:${index + 1}".takeIf { line.contains("MediaUrl.secured(") }
+ }
+ }
+
+ assertEquals(
+ "Streaming an episode from its publisher plays different audio from the one the " +
+ "skips were measured on. If this grew, something is bypassing PlaybackAdvancer:\n" +
+ fallbacks.joinToString("\n"),
+ 1,
+ fallbacks.size,
+ )
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/PlainLanguageTest.kt b/app/src/test/java/org/terst/listen/PlainLanguageTest.kt
new file mode 100644
index 0000000..bb451c0
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/PlainLanguageTest.kt
@@ -0,0 +1,91 @@
+package org.terst.listen
+
+import org.junit.Assert.assertTrue
+import java.io.File
+import org.junit.Test
+
+/**
+ * Nothing the listener reads is written for a machine.
+ *
+ * Added after a red banner on Peter's phone said "Playback failed: ERROR_CODE_IO_BAD_HTTP_STATUS".
+ * His words were "arcane and sketchy", and both halves were fair: it names an internal of a
+ * library he does not use, and it announces an HTTP request without saying to whom or why.
+ *
+ * That was not one slip. The same pass found a downloader printing `${it.message}`, an OPML
+ * import printing `permission denied`, and a feed fetcher putting the literal word
+ * `UnknownHostException` under a podcast's name in the library -- and two tests that *asserted*
+ * the machine text came through, which is how it survived review.
+ *
+ * So this is the ratchet. Diagnosis goes to logcat, where a code is useful; the screen gets a
+ * sentence. The scope is deliberately narrow and honest: it reads the places errors actually
+ * surface, not every string in the app, because a check that tried to judge all prose would
+ * either be noise or be wrong.
+ */
+class PlainLanguageTest {
+
+ private val mainDir = File("src/main/java/org/terst/listen")
+
+ /** Where a cause is turned into something the listener reads. */
+ private val surfaces = Regex("""Banner\(|reportAndFinish\(|FeedFetchResult\.Failed\(""")
+
+ /**
+ * Interpolating any of these into a listener-facing string means the machine's words reach
+ * the screen. `it.message` is the one that kept happening.
+ */
+ private val machineValue = Regex(
+ """\$\{?[\w.]*\b(message|localizedMessage|stackTrace|errorCodeName|javaClass|simpleName)\b""",
+ )
+
+ /** A constant like ERROR_CODE_IO_BAD_HTTP_STATUS, written out where a reader would see it. */
+ private val machineConstant = Regex("""\b[A-Z][A-Z0-9]{2,}_[A-Z0-9_]{2,}\b""")
+
+ /**
+ * Files allowed to name those constants, because naming them is their job: each maps a code
+ * to a sentence, and the map has to say which code.
+ */
+ private val mappers = setOf("PlaybackFailureText.kt", "FeedFailureText.kt", "FailureText.kt")
+
+ private fun sources() = mainDir.walkTopDown().filter { it.extension == "kt" }.toList()
+
+ @Test
+ fun `no listener-facing message is built out of an exception`() {
+ val offenders = mutableListOf<String>()
+ for (file in sources()) {
+ file.readLines().forEachIndexed { index, line ->
+ val code = line.substringBefore("//")
+ if (!surfaces.containsMatchIn(code)) return@forEachIndexed
+ if (machineValue.containsMatchIn(code)) {
+ offenders += "${file.name}:${index + 1} ${code.trim()}"
+ }
+ }
+ }
+ assertTrue(
+ "These put a machine's words in front of the listener. Log the cause and show a " +
+ "sentence -- see PlaybackFailureText, FeedFailureText, FailureText:\n" +
+ offenders.joinToString("\n"),
+ offenders.isEmpty(),
+ )
+ }
+
+ @Test
+ fun `no listener-facing message spells out an error constant`() {
+ val offenders = mutableListOf<String>()
+ for (file in sources()) {
+ if (file.name in mappers) continue
+ file.readLines().forEachIndexed { index, line ->
+ val code = line.substringBefore("//")
+ if (!surfaces.containsMatchIn(code)) return@forEachIndexed
+ Regex(""""([^"]*)"""").findAll(code).forEach { literal ->
+ if (machineConstant.containsMatchIn(literal.groupValues[1])) {
+ offenders += "${file.name}:${index + 1} ${literal.value}"
+ }
+ }
+ }
+ }
+ assertTrue(
+ "A constant is not a sentence. Map it to one in a *FailureText object:\n" +
+ offenders.joinToString("\n"),
+ offenders.isEmpty(),
+ )
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/data/FakeDaos.kt b/app/src/test/java/org/terst/listen/data/FakeDaos.kt
index fc2c558..ffb440a 100644
--- a/app/src/test/java/org/terst/listen/data/FakeDaos.kt
+++ b/app/src/test/java/org/terst/listen/data/FakeDaos.kt
@@ -349,9 +349,6 @@ class FakeMidrollDao : MidrollDao {
/** Backs [getAllMidrollPendingEpisodeIds], which reads the real `subscriptions` table. */
var subscriptionRows: MutableList<SubscriptionEntity> = mutableListOf()
- override suspend fun getCandidates(subscriptionId: String) =
- candidateRows.filter { it.subscriptionId == subscriptionId }
-
override suspend fun getRanges(episodeId: String) = rangeRows.filter { it.episodeId == episodeId }
diff --git a/app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt b/app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt
index 1c8cda1..a6df6ff 100644
--- a/app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt
+++ b/app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt
@@ -11,6 +11,7 @@ import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
+import org.terst.listen.feed.FeedFailureText
import org.terst.listen.feed.FeedFetcher
/**
@@ -110,7 +111,9 @@ class SubscriptionRepositoryTest {
assertTrue(outcome is RefreshOutcome.Failed)
val stored = subs.rows.single()
assertNotNull("the reason must be stored, not just logged", stored.lastError)
- assertTrue(stored.lastError!!.contains("500"))
+ // The reason is shown under the show's name in the library, so it is a sentence rather
+ // than a status line. What matters here is that something usable was stored.
+ assertEquals(FeedFailureText.status(500), stored.lastError)
}
@Test
diff --git a/app/src/test/java/org/terst/listen/feed/FeedArchiveWalkerTest.kt b/app/src/test/java/org/terst/listen/feed/FeedArchiveWalkerTest.kt
index b37e07c..7c3885a 100644
--- a/app/src/test/java/org/terst/listen/feed/FeedArchiveWalkerTest.kt
+++ b/app/src/test/java/org/terst/listen/feed/FeedArchiveWalkerTest.kt
@@ -119,6 +119,6 @@ class FeedArchiveWalkerTest {
assertEquals(1, walk.pages)
assertEquals(listOf("p2a"), walk.episodes.map { it.guid })
- assertTrue(walk.stoppedEarly.orEmpty(), walk.stoppedEarly.orEmpty().contains("500"))
+ assertEquals(FeedFailureText.status(500), walk.stoppedEarly)
}
}
diff --git a/app/src/test/java/org/terst/listen/feed/FeedFetcherTest.kt b/app/src/test/java/org/terst/listen/feed/FeedFetcherTest.kt
index 75b58d7..2d792e4 100644
--- a/app/src/test/java/org/terst/listen/feed/FeedFetcherTest.kt
+++ b/app/src/test/java/org/terst/listen/feed/FeedFetcherTest.kt
@@ -6,6 +6,7 @@ import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
@@ -84,13 +85,26 @@ class FeedFetcherTest {
assertNull(request.getHeader("If-Modified-Since"))
}
- /** A failure has to be reportable, so it can be recorded against the subscription. */
+ /**
+ * A failure has to be reportable, because it is recorded against the subscription and shown
+ * under the show's name in the library. It used to report "HTTP 500 Internal Server Error";
+ * the status belongs in the log, and what the reader needs is whether to try again.
+ */
@Test
- fun `a server error is a failure carrying its status`() = runTest {
+ fun `a server error says it is worth trying later, not what the status was`() = runTest {
server.enqueue(MockResponse().setResponseCode(500))
val result = fetcher.fetch(server.url("/f").toString(), null, null)
val failed = result as FeedFetchResult.Failed
- assertTrue("message should name the status: ${failed.message}", failed.message.contains("500"))
+ assertEquals(FeedFailureText.status(500), failed.message)
+ assertFalse("a status code is not for the listener: ${failed.message}", failed.message.contains("500"))
+ }
+
+ /** A feed that has moved is a different decision from one having a bad day, and says so. */
+ @Test
+ fun `a missing feed says it has gone rather than to try later`() = runTest {
+ server.enqueue(MockResponse().setResponseCode(404))
+ val failed = fetcher.fetch(server.url("/f").toString(), null, null) as FeedFetchResult.Failed
+ assertEquals("That feed is no longer at this address.", failed.message)
}
@Test
diff --git a/app/src/test/java/org/terst/listen/fingerprint/ShowDetectionStateTest.kt b/app/src/test/java/org/terst/listen/fingerprint/ShowDetectionStateTest.kt
new file mode 100644
index 0000000..6465893
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/fingerprint/ShowDetectionStateTest.kt
@@ -0,0 +1,55 @@
+package org.terst.listen.fingerprint
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/**
+ * What a show's settings sheet says the server has worked out.
+ *
+ * This replaces a readout that could not say anything. It read `show_fingerprints` and
+ * `show_midrolls` -- the confirmation state the *on-device* detector kept -- and nothing has
+ * written a row of either since detection moved to the server, which writes per-episode offsets
+ * and ranges instead. So every show reported "Nothing found yet" for ever, including shows the
+ * server was actively publishing breaks for. Confirmed on a phone before this was rewritten.
+ *
+ * The three states that matter are the ones CLAUDE.md names: looked-and-found, looked-and-found-
+ * nothing, and not-looked-yet. They are different answers and the old readout collapsed two of
+ * them.
+ */
+class ShowDetectionStateTest {
+
+ @Test
+ fun `nothing checked yet is not the same as nothing found`() {
+ assertEquals("Not checked yet", SkipSummary.showState(checked = 0, found = 0))
+ }
+
+ @Test
+ fun `checked and found nothing says so, so it is not mistaken for still working`() {
+ assertEquals("None found in 8 episodes", SkipSummary.showState(checked = 8, found = 0))
+ }
+
+ @Test
+ fun `found says how widely, because one episode out of eight is a different thing from eight`() {
+ assertEquals("Found in 6 of 8 episodes", SkipSummary.showState(checked = 8, found = 6))
+ assertEquals("Found in 1 of 8 episodes", SkipSummary.showState(checked = 8, found = 1))
+ }
+
+ /** Every episode carrying it is the ordinary healthy case, and reads better without a ratio. */
+ @Test
+ fun `found everywhere drops the ratio`() {
+ assertEquals("Found in all 8 episodes", SkipSummary.showState(checked = 8, found = 8))
+ assertEquals("Found in the 1 episode checked", SkipSummary.showState(checked = 1, found = 1))
+ }
+
+ /** Singular, because "None found in 1 episodes" is the kind of thing that erodes trust. */
+ @Test
+ fun `one episode reads as one episode`() {
+ assertEquals("None found in 1 episode", SkipSummary.showState(checked = 1, found = 0))
+ }
+
+ /** Nonsense in must not produce confident nonsense out. */
+ @Test
+ fun `found without checked cannot happen and does not pretend to`() {
+ assertEquals("Not checked yet", SkipSummary.showState(checked = 0, found = 3))
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt b/app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt
index f1e6b8c..9794fda 100644
--- a/app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt
+++ b/app/src/test/java/org/terst/listen/fingerprint/SkipSummaryTest.kt
@@ -8,7 +8,6 @@ import org.terst.listen.data.EpisodeEntity
import org.terst.listen.data.EpisodeMidrollEntity
import org.terst.listen.data.FingerprintKind
import org.terst.listen.data.ShowFingerprintEntity
-import org.terst.listen.data.ShowMidrollEntity
/**
* The words the app uses to say what the skip detection has worked out.
@@ -66,89 +65,6 @@ class SkipSummaryTest {
audioFromServer = audioFromServer ?: false,
)
- private fun candidate(id: String, confirmedCount: Int) = ShowMidrollEntity(
- subscriptionId = "s1",
- id = id,
- referenceClipPath = "/clips/s1/$id.pcm",
- sampleRate = 8000,
- confirmedCount = confirmedCount,
- lastConfirmedEpisodeId = null,
- updatedAtMs = 0,
- )
-
- @Test
- fun nothingSeenYet() {
- assertEquals("Nothing found yet", SkipSummary.candidateState(null, threshold = 3))
- }
-
- /** One episode in, there is a clip set aside but nothing to compare it against. */
- @Test
- fun waitingForASecondEpisode() {
- assertEquals(
- "Nothing found yet",
- SkipSummary.candidateState(fingerprint(0, hasReference = false, pendingEpisodeId = "e1"), 3),
- )
- }
-
- @Test
- fun gatheringNamesHowFarOffItIs() {
- assertEquals("Found, 2 of 3 episodes agree", SkipSummary.candidateState(fingerprint(2), 3))
- }
-
- /** The distinction that matters: below the threshold nothing is being skipped. */
- @Test
- fun confirmedSaysSo() {
- assertEquals("Confirmed by 3 episodes", SkipSummary.candidateState(fingerprint(3), 3))
- }
-
- @Test
- fun confirmedKeepsCountingPastTheThreshold() {
- assertEquals("Confirmed by 7 episodes", SkipSummary.candidateState(fingerprint(7), 3))
- }
-
- @Test
- fun noMidrollCandidates() {
- assertEquals("None found yet", SkipSummary.midrollState(emptyList(), threshold = 3))
- }
-
- @Test
- fun midrollsCountBothStates() {
- val candidates = listOf(candidate("a", 4), candidate("b", 3), candidate("c", 2))
- assertEquals("2 confirmed, 1 still gathering", SkipSummary.midrollState(candidates, 3))
- }
-
- @Test
- fun midrollsGatheringOnly() {
- assertEquals("1 still gathering", SkipSummary.midrollState(listOf(candidate("a", 2)), 3))
- }
-
- @Test
- fun midrollsConfirmedOnly() {
- assertEquals("1 confirmed", SkipSummary.midrollState(listOf(candidate("a", 3)), 3))
- }
-
-
- /**
- * The three numbers Peter asked for as a sanity check, on one line: what was served, what the
- * feed declared, and how much the breaks cut. Served first because it is the one measured
- * against the actual file.
- */
- @Test
- fun `shows served, declared and cut on one line`() {
- val skips = SkipSummary.episode(
- episodeRow(durationMs = 1_325_000, actualDurationMs = 1_774_000, midrollCheckedAtMs = 1),
- listOf(
- EpisodeMidrollEntity("e1", "c1", startMs = 120_000, endMs = 180_000),
- EpisodeMidrollEntity("e1", "c2", startMs = 600_000, endMs = 690_000),
- ),
- )
-
- assertEquals("29:34", skips.served)
- assertEquals("22:05", skips.declared)
- assertEquals("2:30", skips.cut)
- assertEquals("Served 29:34 \u00b7 Declared 22:05 \u00b7 Cut 2:30", skips.numbers)
- assertEquals(null, skips.status)
- }
/** Never played, so the file has not been measured: omitted, not shown as a zero or a dash. */
@Test
diff --git a/app/src/test/java/org/terst/listen/playback/PlaybackAdvancerTest.kt b/app/src/test/java/org/terst/listen/playback/PlaybackAdvancerTest.kt
index eba2629..04b31ff 100644
--- a/app/src/test/java/org/terst/listen/playback/PlaybackAdvancerTest.kt
+++ b/app/src/test/java/org/terst/listen/playback/PlaybackAdvancerTest.kt
@@ -4,6 +4,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.terst.listen.data.BreakVerdictEntity
@@ -127,6 +128,43 @@ class PlaybackAdvancerTest {
* Marking a break wrong has to stop it being skipped from the next tick, and the context is
* read once per episode -- so the ticker asks for the verdicts afresh each time.
*/
+ /*
+ An episode with no file is fetched, not streamed from the publisher.
+
+ `toMediaItem` does `setUri(localPath ?: mediaUrl)`, so until now pressing play on an
+ undownloaded podcast opened the publisher's URL directly. Three things wrong with that, and
+ the first is the one that shows:
+
+ - The publisher's CDN answers to Media3, not to the app's own fetch, and when it answers
+ with a non-2xx the listener gets "Playback failed: ERROR_CODE_IO_BAD_HTTP_STATUS".
+ - It is the automatic publisher fallback Peter removed from the download path on
+ 2026-10-17 ("Do not reintroduce an automatic fallback for a transient failure"),
+ surviving in the play path where nobody looked for it.
+ - Worst: nothing in playback checks `audioFromServer`, so the breaks the server measured on
+ *its* copy are applied to the publisher's, which has different advertising in different
+ places. The skips land in the wrong spot and the listener cannot tell why.
+
+ Fetching first is what the download path already does, and what YouTube already does here.
+ */
+ @Test
+ fun `an episode with no file is fetched rather than streamed from its publisher`() = runTest {
+ show("s1", kind = SubscriptionKind.PODCAST)
+ episode("e1", localPath = null)
+
+ val outcome = advancer.start("e1")
+
+ assertEquals(SubscriptionKind.PODCAST, (outcome as? StartOutcome.NeedsFetch)?.kind)
+ assertEquals("e1", (outcome as? StartOutcome.NeedsFetch)?.episode?.id)
+ }
+
+ @Test
+ fun `an episode already on disk plays from it`() = runTest {
+ show("s1", kind = SubscriptionKind.PODCAST)
+ episode("e1", localPath = "/tmp/e1.mp3")
+
+ assertTrue(advancer.start("e1") is StartOutcome.Ready)
+ }
+
@Test
fun `fresh verdicts are read each time rather than once per episode`() = runTest {
show()
@@ -165,8 +203,8 @@ class PlaybackAdvancerTest {
@Test
fun `finishing drops it from the queue and reports what is next`() = runTest {
show()
- episode("a")
- episode("b")
+ episode("a", localPath = "/audio/a.mp3")
+ episode("b", localPath = "/audio/b.mp3")
queue.append("a")
queue.append("b")
@@ -205,7 +243,7 @@ class PlaybackAdvancerTest {
@Test
fun `a fresh episode starts past the show's intro`() = runTest {
show(skipIntroMs = 47_000)
- episode("a")
+ episode("a", localPath = "/audio/a.mp3")
assertEquals(47_000L, advancer.start("a").ready()?.startPositionMs)
}
@@ -213,7 +251,7 @@ class PlaybackAdvancerTest {
@Test
fun `a part-heard episode resumes where it was left`() = runTest {
show(skipIntroMs = 47_000)
- episode("a", positionMs = 12 * minute)
+ episode("a", localPath = "/audio/a.mp3", positionMs = 12 * minute)
assertEquals(12 * minute, advancer.start("a").ready()?.startPositionMs)
}
@@ -221,8 +259,8 @@ class PlaybackAdvancerTest {
@Test
fun `the show's speed is used, and a rule's beats it`() = runTest {
show(playbackSpeed = 1.5f)
- episode("a")
- episode("b", speedOverride = 1.0f)
+ episode("a", localPath = "/audio/a.mp3")
+ episode("b", localPath = "/audio/b.mp3", speedOverride = 1.0f)
assertEquals(1.5f, advancer.start("a").ready()?.speed)
assertEquals(1.0f, advancer.start("b").ready()?.speed)
@@ -232,8 +270,8 @@ class PlaybackAdvancerTest {
fun `the next episode carries its own show's settings, not the finished one's`() = runTest {
show(id = "s1", skipIntroMs = 0)
show(id = "s2", skipIntroMs = 30_000, playbackSpeed = 2.0f)
- episode("a", subscriptionId = "s1")
- episode("b", subscriptionId = "s2")
+ episode("a", localPath = "/audio/a.mp3", subscriptionId = "s1")
+ episode("b", localPath = "/audio/b.mp3", subscriptionId = "s2")
queue.append("a")
queue.append("b")
@@ -276,13 +314,19 @@ class PlaybackAdvancerTest {
assertNotNull(advancer.start("a").ready())
}
- /** An ordinary podcast enclosure is a real audio URL and streams without any of this. */
+ /**
+ * It used to, and that was the bug.
+ *
+ * A podcast enclosure is a real audio URL, so streaming it worked -- which is why nobody
+ * noticed it was the publisher's differently-ad-inserted copy being played with the server's
+ * offsets applied to it. A podcast is fetched first now, the same as any other kind.
+ */
@Test
- fun `a podcast episode starts without being downloaded`() = runTest {
+ fun `a podcast episode is not started straight off its publisher`() = runTest {
show()
episode("a")
- assertNotNull(advancer.start("a").ready())
+ assertEquals(SubscriptionKind.PODCAST, (advancer.start("a") as? StartOutcome.NeedsFetch)?.kind)
}
@Test
diff --git a/app/src/test/java/org/terst/listen/playback/PlaybackFailureTextTest.kt b/app/src/test/java/org/terst/listen/playback/PlaybackFailureTextTest.kt
new file mode 100644
index 0000000..0c47bee
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/playback/PlaybackFailureTextTest.kt
@@ -0,0 +1,80 @@
+package org.terst.listen.playback
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * What the listener is told when playback stops.
+ *
+ * It used to be the Media3 constant, printed raw: "Playback failed: ERROR_CODE_IO_BAD_HTTP_STATUS".
+ * Peter's words for that were "arcane and sketchy", and both halves are fair. It names an
+ * implementation detail of a library he does not use, and it says an HTTP request happened --
+ * which was true, and was itself the bug: the player was streaming from the publisher.
+ *
+ * So these are checked for being sentences, not codes. The raw name still goes to logcat, where
+ * diagnosis belongs.
+ */
+class PlaybackFailureTextTest {
+
+ @Test
+ fun `a refused request says who refused it, not what the code was called`() {
+ val text = PlaybackFailureText.of("ERROR_CODE_IO_BAD_HTTP_STATUS")
+
+ assertEquals("The server refused to send this episode's audio.", text)
+ }
+
+ @Test
+ fun `a missing file says the download has gone, which is the thing to act on`() {
+ assertEquals(
+ "This episode's download is missing. Download it again.",
+ PlaybackFailureText.of("ERROR_CODE_IO_FILE_NOT_FOUND"),
+ )
+ }
+
+ @Test
+ fun `a dropped connection says so plainly`() {
+ val expected = "No connection, so this episode could not be played."
+ assertEquals(expected, PlaybackFailureText.of("ERROR_CODE_IO_NETWORK_CONNECTION_FAILED"))
+ assertEquals(expected, PlaybackFailureText.of("ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT"))
+ }
+
+ @Test
+ fun `audio that will not decode says the file is the problem`() {
+ assertEquals(
+ "This episode's audio could not be played; the file may be damaged.",
+ PlaybackFailureText.of("ERROR_CODE_DECODING_FAILED"),
+ )
+ }
+
+ /**
+ * The honest answer when there is no better one. It must not pretend to a cause it does not
+ * know, and it must not fall back to printing the code.
+ */
+ @Test
+ fun `an unrecognised failure says only what is true`() {
+ val text = PlaybackFailureText.of("ERROR_CODE_SOMETHING_NOBODY_MAPPED")
+
+ assertEquals("This episode stopped playing.", text)
+ }
+
+ /** The property that matters most, across every branch: no machine words reach the screen. */
+ @Test
+ fun `no message leaks a code, an underscore constant or a class name`() {
+ val codes = listOf(
+ "ERROR_CODE_IO_BAD_HTTP_STATUS", "ERROR_CODE_IO_FILE_NOT_FOUND",
+ "ERROR_CODE_IO_NETWORK_CONNECTION_FAILED", "ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT",
+ "ERROR_CODE_DECODING_FAILED", "ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED",
+ "ERROR_CODE_SOMETHING_NOBODY_MAPPED", "", "java.io.IOException",
+ )
+ for (code in codes) {
+ val text = PlaybackFailureText.of(code)
+ assertFalse("$code leaked a constant: $text", text.contains("ERROR_"))
+ assertFalse("$code leaked an underscore: $text", text.contains("_"))
+ assertFalse("$code leaked a class name: $text", text.contains("Exception"))
+ assertTrue("$code produced nothing", text.isNotBlank())
+ assertTrue("$code should end as a sentence: $text", text.trimEnd().endsWith("."))
+ }
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/ui/FakeUiGraph.kt b/app/src/test/java/org/terst/listen/ui/FakeUiGraph.kt
index 5b43bdb..705d830 100644
--- a/app/src/test/java/org/terst/listen/ui/FakeUiGraph.kt
+++ b/app/src/test/java/org/terst/listen/ui/FakeUiGraph.kt
@@ -7,6 +7,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import okhttp3.OkHttpClient
+import org.terst.listen.data.DownloadState
import org.terst.listen.data.EpisodeEntity
import org.terst.listen.data.FakeBreakVerdictDao
import org.terst.listen.data.FakeEpisodeDao
@@ -169,6 +170,14 @@ fun episodeRow(
subscriptionId: String = "s1",
publishedAtMs: Long? = 1_000,
positionMs: Long = 0,
+ /**
+ * Downloaded by default, because that is the only state that plays.
+ *
+ * PlaybackAdvancer fetches an episode with no file rather than streaming its publisher, so a
+ * fixture without one never reaches the player at all -- which is a fine thing to test, and
+ * not what most of these tests are about.
+ */
+ localPath: String? = "/audio/$id.mp3",
) = EpisodeEntity(
id = id,
subscriptionId = subscriptionId,
@@ -177,6 +186,8 @@ fun episodeRow(
mediaUrl = "https://cdn/$id.mp3",
mediaType = "audio/mpeg",
discoveredAtMs = 0,
+ localPath = localPath,
+ downloadState = if (localPath != null) DownloadState.DOWNLOADED else DownloadState.NONE,
publishedAtMs = publishedAtMs,
positionMs = positionMs,
durationMs = 600_000,
diff --git a/app/src/test/java/org/terst/listen/ui/ListenViewModelTest.kt b/app/src/test/java/org/terst/listen/ui/ListenViewModelTest.kt
index 4d60762..89fa45f 100644
--- a/app/src/test/java/org/terst/listen/ui/ListenViewModelTest.kt
+++ b/app/src/test/java/org/terst/listen/ui/ListenViewModelTest.kt
@@ -314,7 +314,8 @@ class ListenViewModelTest {
val banner = awaitBanner()
assertTrue(banner.isError)
- assertTrue(banner.text.startsWith("Download failed: "))
+ // Not "Download failed: <whatever OkHttp said>"; the exception goes to logcat.
+ assertEquals(FailureText.DOWNLOAD, banner.text)
}
/**
diff --git a/app/src/test/java/org/terst/listen/ui/OpmlViewModelTest.kt b/app/src/test/java/org/terst/listen/ui/OpmlViewModelTest.kt
index af44f48..a0a3804 100644
--- a/app/src/test/java/org/terst/listen/ui/OpmlViewModelTest.kt
+++ b/app/src/test/java/org/terst/listen/ui/OpmlViewModelTest.kt
@@ -8,6 +8,7 @@ import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
@@ -120,12 +121,17 @@ class OpmlViewModelTest {
assertEquals(Banner("No subscriptions found in that file", isError = true), awaitBanner())
}
+ /**
+ * It used to append the exception's own text, so a listener got "permission denied" or an
+ * SELinux path. What they can act on is the file, so that is what it names.
+ */
@Test
- fun `a file that cannot be read says why`() {
+ fun `a file that cannot be read says so without quoting the exception`() {
vm.importFrom { throw IOException("permission denied") }
val banner = awaitBanner()
- assertEquals("Could not read that file: permission denied", banner.text)
+ assertEquals(FailureText.OPML_READ, banner.text)
+ assertFalse("the exception's text is for logcat", banner.text.contains("permission denied"))
assertTrue(banner.isError)
}
@@ -155,11 +161,12 @@ class OpmlViewModelTest {
}
@Test
- fun `an export that cannot be written says why`() {
+ fun `an export that cannot be written says so without quoting the exception`() {
vm.exportTo { throw IOException("read-only") }
val banner = awaitBanner()
- assertEquals("Export failed: read-only", banner.text)
+ assertEquals(FailureText.OPML_WRITE, banner.text)
+ assertFalse("the exception's text is for logcat", banner.text.contains("read-only"))
assertTrue(banner.isError)
}
diff --git a/app/src/test/java/org/terst/listen/ui/PlayerViewModelTest.kt b/app/src/test/java/org/terst/listen/ui/PlayerViewModelTest.kt
index 3195f46..1fb75f8 100644
--- a/app/src/test/java/org/terst/listen/ui/PlayerViewModelTest.kt
+++ b/app/src/test/java/org/terst/listen/ui/PlayerViewModelTest.kt
@@ -6,6 +6,7 @@ import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
@@ -14,6 +15,7 @@ import org.junit.Test
import org.terst.listen.data.SubscriptionEntity
import org.terst.listen.data.SubscriptionKind
import org.terst.listen.playback.BedtimePolicy
+import org.terst.listen.playback.PlaybackFailureText
import org.terst.listen.playback.PlaybackSpeed
import org.terst.listen.playback.SleepTimer
@@ -111,7 +113,7 @@ class PlayerViewModelTest {
@Test
fun `a youtube episode is fetched before it plays, and says so while it is`() {
show(kind = SubscriptionKind.YOUTUBE_CHANNEL)
- graph.episodes.rows += episodeRow("y1").copy(title = "A video")
+ graph.episodes.rows += episodeRow("y1", localPath = null).copy(title = "A video")
val seen = mutableListOf<String?>()
graph.fetchAudio = { episode, _ ->
seen += vm.preparing.value
@@ -135,7 +137,7 @@ class PlayerViewModelTest {
@Test
fun `a fetch that fails says why, and stops claiming to be preparing`() {
show(kind = SubscriptionKind.YOUTUBE_CHANNEL)
- graph.episodes.rows += episodeRow("y1")
+ graph.episodes.rows += episodeRow("y1", localPath = null)
graph.fetchAudio = { _, _ -> Result.failure(IllegalStateException("video unavailable")) }
vm.play(graph.episodes.rows.single())
@@ -195,10 +197,13 @@ class PlayerViewModelTest {
controls.onError!!("ERROR_CODE_IO_BAD_HTTP_STATUS")
+ // The sentence, not the constant. PlaybackFailureText owns the wording and is tested
+ // there; what this pins is that the view model asks it rather than printing the code.
assertEquals(
- Banner("Playback failed: ERROR_CODE_IO_BAD_HTTP_STATUS", isError = true),
+ Banner(PlaybackFailureText.of("ERROR_CODE_IO_BAD_HTTP_STATUS"), isError = true),
vm.banner.value,
)
+ assertFalse("a Media3 constant must not reach the screen", vm.banner.value!!.text.contains("ERROR_"))
vm.dismissBanner()
assertNull(vm.banner.value)
}
diff --git a/app/src/test/java/org/terst/listen/ui/ShowSettingsViewModelTest.kt b/app/src/test/java/org/terst/listen/ui/ShowSettingsViewModelTest.kt
index 4ec0554..0ebd559 100644
--- a/app/src/test/java/org/terst/listen/ui/ShowSettingsViewModelTest.kt
+++ b/app/src/test/java/org/terst/listen/ui/ShowSettingsViewModelTest.kt
@@ -13,9 +13,7 @@ import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
-import org.terst.listen.data.FingerprintKind
-import org.terst.listen.data.ShowFingerprintEntity
-import org.terst.listen.data.ShowMidrollEntity
+import org.terst.listen.data.EpisodeMidrollEntity
import org.terst.listen.data.ShowTier
import org.terst.listen.data.SubscriptionEntity
import java.util.concurrent.TimeUnit
@@ -267,51 +265,57 @@ class ShowSettingsViewModelTest {
// --- the detection readout ----------------------------------------------------------
+ /*
+ The readout is counted off the episodes, because that is where the server's answers land.
+
+ It used to read show-level confirmation rows written by the on-device detector, and nothing
+ has written one since detection moved. Every show said "Nothing found yet" for ever --
+ verified on a phone, on a show the server was actively publishing breaks for.
+ */
@Test
- fun `a show nothing has been found for reads as three empty states`() {
+ fun `a show whose episodes the server has not supplied reads as not checked`() {
show()
+ graph.episodes.rows += episodeRow("e1").copy(audioFromServer = false)
vm.loadDetection("s1")
assertEquals(
- ShowDetection(
- intro = "Nothing found yet",
- outro = "Nothing found yet",
- midrolls = "None found yet",
- ),
+ ShowDetection("Not checked yet", "Not checked yet", "Not checked yet"),
vm.detection.value,
)
}
+ /** Looked and found nothing is a different answer from not looked, and reads differently. */
@Test
- fun `a confirmed intro and a gathering midroll read differently`() {
+ fun `episodes the server supplied with no offsets read as none found`() {
show()
- graph.fingerprints.rows += ShowFingerprintEntity(
- subscriptionId = "s1",
- kind = FingerprintKind.INTRO,
- referenceClipPcm = byteArrayOf(1, 2),
- pendingEpisodeId = null,
- sampleRate = 8_000,
- confirmedCount = 9,
- lastConfirmedEpisodeId = "e1",
- updatedAtMs = 0,
+ graph.episodes.rows += episodeRow("e1").copy(audioFromServer = true)
+ graph.episodes.rows += episodeRow("e2").copy(audioFromServer = true)
+
+ vm.loadDetection("s1")
+
+ assertEquals(
+ ShowDetection("None found in 2 episodes", "None found in 2 episodes", "None found in 2 episodes"),
+ vm.detection.value,
)
- graph.midrolls.candidateRows += ShowMidrollEntity(
- subscriptionId = "s1",
- id = "c1",
- referenceClipPath = "/dev/null",
- sampleRate = 8_000,
- confirmedCount = 1,
- lastConfirmedEpisodeId = "e1",
- updatedAtMs = 0,
+ }
+
+ @Test
+ fun `what the server did find is counted, and the ends are read separately`() {
+ show()
+ graph.episodes.rows += episodeRow("e1").copy(audioFromServer = true, detectedIntroMs = 47_000)
+ graph.episodes.rows += episodeRow("e2").copy(audioFromServer = true, detectedIntroMs = 46_000)
+ graph.episodes.rows += episodeRow("e3").copy(audioFromServer = true)
+ graph.midrolls.rangeRows += EpisodeMidrollEntity(
+ episodeId = "e1", candidateId = "c1", startMs = 60_000, endMs = 120_000, kind = "ad",
)
vm.loadDetection("s1")
val detection = vm.detection.value!!
- assertEquals("Confirmed by 9 episodes", detection.intro)
- assertEquals("the ends are read separately", "Nothing found yet", detection.outro)
- assertEquals("1 still gathering", detection.midrolls)
+ assertEquals("Found in 2 of 3 episodes", detection.intro)
+ assertEquals("the ends are read separately", "None found in 3 episodes", detection.outro)
+ assertEquals("Found in 1 of 3 episodes", detection.midrolls)
}
private companion object {