<feed xmlns='http://www.w3.org/2005/Atom'>
<title>listen.git/app, branch main</title>
<subtitle>Listen — personal Android podcast app
</subtitle>
<id>https://git.terst.org/listen.git/atom?h=main</id>
<link rel='self' href='https://git.terst.org/listen.git/atom?h=main'/>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/'/>
<updated>2026-11-14T07:25:36+00:00</updated>
<entry>
<title>Hold a show to play it, and put the second row the right way round</title>
<updated>2026-11-14T07:25:36+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-11-14T07:25:36+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=d4738ee16513b86d9e66480ea10b0b19a7f9809c'/>
<id>urn:sha1:d4738ee16513b86d9e66480ea10b0b19a7f9809c</id>
<content type='text'>
Two of Peter's, both on surfaces that already had the pieces.

Holding a show in the library plays what it is offering, the same gesture the
episode list has carried since the row buttons changed. Playing a show's newest
episode meant opening the show first, which is two taps for the thing you want
most often. A show offering nothing does nothing when held, rather than
something arbitrary -- tapping still opens it either way.

The transport's second row reads sleep, speed, ad. It was the reverse, which
was my ordering and not a considered one. Sleep is set deliberately before
settling down so it takes the near edge; the ad button is hit mid-listen when
something slips through, so it sits furthest out where a stray press costs the
least.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>The player sends the token in a header too, not just the downloader</title>
<updated>2026-11-11T16:28:04+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-11-11T16:28:04+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=58d87473d4da30069abbf72c352d08c40b6c56eb'/>
<id>urn:sha1:58d87473d4da30069abbf72c352d08c40b6c56eb</id>
<content type='text'>
The last thing standing between the token and the query string it should not
be in. The downloader moved to an Authorization header a while back; the player
never did, and the player is how the car plays anything not already on disk.
Withdrawing the query parameter without this would have broken streaming in the
car silently -- a 401 arriving as "playback failed".

ExoPlayer resolves a URL on its own loading thread with no coroutine to read
DataStore in, so ServerAudioAuth caches the configuration and the session
callback refreshes it before handing over any stream URL. Nothing is cached per
URL: the same player fetches publisher audio, and the whole point of
ServerBearer is that those get nothing. Attached through a ResolvingDataSource
rather than as a default request property for the same reason -- a default
would send Peter's server credentials to every podcast CDN he subscribes to.

Before the first refresh it sends nothing, which is the safe direction: a
stream that should have carried the token fails visibly, where one that should
not have carried it would have leaked it.

Verified against the live server, not a fake: the player reaches READY on
listen.terst.org audio with a header and no token in the URL, and the same URL
without credentials is still refused. The token is supplied as an
instrumentation argument and is not in the repository -- a test file carrying
it would be the same mistake in a different file -- so the test skips unless
one is passed.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>The car groups shows by category, and a silent show says so</title>
<updated>2026-11-11T16:19:08+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-11-11T16:19:08+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=4d352677070e6e5b0f2b32c85f058a4108a86ac6'/>
<id>urn:sha1:4d352677070e6e5b0f2b32c85f058a4108a86ac6</id>
<content type='text'>
Two things, both about finding something rather than playing it.

Android Auto lists category folders under Podcasts instead of forty-five shows
in a row -- a car screen holds six to eight, so anything not near the top was
several flicks away. The categories already existed and already had an order,
so this costs no new data. With only one category present the folders would
separate nothing, so the shows are listed directly and the level disappears.

A show that has not published in a year says so above its drip control:
"No new episodes in 3 years". That is the whole of the special treatment a
finished show gets -- no flag, no automatic behaviour, no guessing that a show
is over. A run of silence is a fact and "finished" is not. A year, because
shorter fires on everything merely between seasons, and a line that appears on
everything says nothing. It costs no extra query: the sheet already reads every
episode for the detection readout.

The browse id format moved out of LibraryTree into BrowseIds, because that
object went past its function limit and the split is along a real line. The
format is the seam that has actually broken -- an id that does not survive the
round trip to the browser is what made Up Next show something other than the
queue -- and it could not be tested off a device before, because LibraryTree
builds Media3 items. It has six tests now, including that an episode id and a
show id carrying the same raw string are never confused for one another.

Verified over the real session protocol on the emulator: the folders appear,
the News folder holds the news show and not the fiction one. 8 browse tests.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>The now playing screen is the size of the thing it is for</title>
<updated>2026-11-11T10:06:17+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-11-11T10:06:17+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=e6636c49251785af218f5a44970fa662fc2bdccf'/>
<id>urn:sha1:e6636c49251785af218f5a44970fa662fc2bdccf</id>
<content type='text'>
Four things Peter asked for, all on the episode screen, which is the now
playing screen -- there is no separate player.

The artwork is 72% of the width rather than a 96dp thumbnail, so it reads as
the artwork of what is playing instead of a list icon. Short of full width on
purpose: the title and the transport still have to be on the first screen.

The three controls you press without looking -- back, the primary, forward --
have a row to themselves at a size worth aiming at. Five controls on one line
made all five small. The ad button, the speed and the sleep timer share a
quieter row below.

The sleep timer is on the screen now instead of three taps into the overflow
menu, which is the wrong depth for something reached for in the dark. The chip
carries what the timer is waiting for -- it reads 14:55 while a countdown runs
and "End of episode" in the other mode -- so nothing has to be opened to find
out. It opens the same dialog the menu does; one set of durations, one
explanation of the fade, two ways in.

The notes are shown in full and never behind a tap. The fold existed so long
notes could not push the transport off the screen, and the transport moved
above them when the two screens merged -- so all it still did was hide the half
of the screen it was there to protect. DescriptionPreview and
ExpandableDescription go with it.

The wording moved to SleepLabel, which is tested: it was inline in the menu and
again in the dialog, and the chip would have been a third copy.

Verified on the emulator rather than from the diff: the screenshot shows all
four, the chip opened the dialog, and arming 15m turned the chip into a running
countdown.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Two instrumented tests had gone stale, and nothing was running them</title>
<updated>2026-11-11T09:50:42+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-11-11T09:50:42+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=e77fb81d9ec3e4c1b1d6ea2d2103660d121d8ace'/>
<id>urn:sha1:e77fb81d9ec3e4c1b1d6ea2d2103660d121d8ace</id>
<content type='text'>
Both describe behaviour that was deliberately changed and neither was updated
with it, because the instrumented suite is not in the commit gate: it needs a
device, so it only runs when someone asks for it. 108 tests, two of them
asserting the opposite of what the app now does.

The library row asserted a Download button. The row's three became play next,
queue and mark played a commit ago -- play is a long press on the line and a
queued episode downloads itself -- so the test named a control that no longer
exists. Mark played now has a test of its own; it was the one the change was
for and the only one of the three with none.

The car's resume test seeded an episode with no file, from when playback would
fall back to the publisher's copy. That fallback was removed on purpose, so the
car now starts a fetch and plays nothing this time round -- the test was
asserting a resume against an empty playlist and failing on the position being
zero, which is the right answer to the wrong question. Confirmed from the
device's own log, "nothing to play for resume-ep from outside the app", rather
than inferred. It seeds a downloaded episode now, so the defect it exists for
-- the position being dropped at the session boundary -- is what it measures.

Whole instrumented suite: 109 tests, no failures.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>The car offers Continue, Short and New</title>
<updated>2026-11-11T09:38:50+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-11-11T09:38:50+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=4559946420c4e7317ce68acc34f9ece28492163b'/>
<id>urn:sha1:4559946420c4e7317ce68acc34f9ece28492163b</id>
<content type='text'>
Browsing by show is the wrong shape for driving: it asks you to remember what
you were doing and then find it, which was behind "discovery sucks" and
"scrolling the lists in the car is bad experience". Three lists sit above the
shows now and answer the question actually being asked at a traffic light --
what was I in the middle of, what fits this drive, what is new.

All three come from data the app already keeps. No new column, no migration.

Short cuts off at 20 minutes, which is Peter's call rather than a guess: it
catches the daily news and the shorter curiosity episodes and leaves out
conversation and deep dives. It reads COALESCE(actualDurationMs, durationMs)
-- playbackDurationMs in SQL -- because a feed declaring 22:05 for a file that
runs 28:18 would otherwise put a half-hour episode in a twenty-minute list.
An episode of unknown length is left out; unknown is not short.

Continue is the one list that does not exclude queued episodes, because the
episode you are in the middle of is normally the head of the queue and
excluding it would empty the list in the ordinary case. Short and New exclude
anything started, so finishing something is never buried under a new arrival.

A list holding nothing is left off the root entirely rather than opened and
found empty -- the same dead end the phone's library dims a show for.

The queries are their own narrow CarListDao rather than three more methods on
EpisodeDao, which detekt was right to call already the widest in the app: they
are read-only, they all cross every show rather than belonging to one, and
they exist for a single surface. Not on UiGraph, since no view model wants them.

Verified on the emulator: 9 CarListsDbTest cases against the real tables, and
MediaBrowseTest walks the real session protocol to the Continue node and finds
the part-heard episode in it.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Shows have a category, not a tier</title>
<updated>2026-11-11T08:54:52+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-11-11T08:54:52+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=aba4fb18ef8a542865e10a891081681352b64fcd'/>
<id>urn:sha1:aba4fb18ef8a542865e10a891081681352b64fcd</id>
<content type='text'>
Tier said "how important" and the shows needed "what kind". Seven ordered
categories replace it: news, analysis, curiosity, sailing, true stories,
fiction, deep dives. The order is still the server's processing priority, so
one field carries both axes -- which is the idiosyncrasy docs/show-kinds.md
is written to watch, with four signs that a better abstraction has arrived.

All 42 known feeds are refiled by migration 20 -&gt; 21, which rebuilds the
subscriptions table rather than renaming the column: RENAME COLUMN keeps the
old 'ordinary' DEFAULT and fails schema validation, and DROP COLUMN needs
SQLite 3.35 against minSdk 29's 3.28. The wire value for true stories is
"truestory", not "true", because SQLite reads an unquoted true in a DEFAULT
as the boolean and stores 1.

Schema 21 is a new exported JSON; migrate20To21RenamesTierAndRefilesTheKnownShows
covers it, and all 22 device migration tests pass.

The coverage exclusion for the settings composable follows the file's rename;
left pointing at TierSettingKt it read as a 0.6% drop. The remaining 0.08% is
the deletion of the covered tier-seeding code, taken as an exception.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>The car plays the queue again, and the lists get out of the way</title>
<updated>2026-11-11T00:02:04+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-11-11T00:02:04+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=8db54bc24d7d5d0cdfce8a2e19199d7e9a42fbd4'/>
<id>urn:sha1:8db54bc24d7d5d0cdfce8a2e19199d7e9a42fbd4</id>
<content type='text'>
**Android Auto was broken by my own fix, and this is the third attempt at the same decision.**
It started by falling back to the publisher's enclosure, which plays a differently-ad-inserted
copy so the measured breaks cut into the programme. Fixing that made the browse callback
*download* first -- tens of megabytes inside the future Auto is waiting on -- so the car reported
"failed to load" and the episode played later only because the download had finished by itself.
That is exactly what Peter saw.

The distinction missed twice: streaming is not the problem, streaming *the publisher's copy* is.
The server's copy is the audio the offsets were measured against, so playing it is as correct as
playing the file and it starts at once. The car asks the server (one small request), plays the
download if there is one, the server's copy otherwise, and nothing at all if neither -- never the
publisher. The fetch still happens, on its own coroutine, so the next attempt has the file.

OutboundRequestsTest said "no remote media item", which was the wrong rule and is why it had to
be relaxed to let this through. It now says what was always meant: nothing in the playback
package may touch `mediaUrl`, the address the *publisher* nominated. Watched failing.

**The car's Up Next was not the queue, and one episode looped.** Both are the same bug, and not
in the browse tree -- that listed the real queue all along. Tapping an episode set the *player's*
playlist to that one item, so the car's own Up Next showed one entry and a single-item playlist
repeats instead of moving on. The playlist is now the queue, starting at the episode tapped. An
episode browsed from a show is not in the queue and still plays alone, because dragging the queue
in behind it would quietly change what plays next.

ReachabilityTest then found `startFetchingIfNeeded` had no callers left -- it existed only for the
car path this replaces -- so it is gone, with the two tests that were really about `fetchAndStart`
rewritten against it rather than deleted with it.

**The lists, as asked:**

- The library row offered play, queue, download. It now offers play next, queue, mark played.
  Play is a long press and download happens by itself for anything queued; what was missing was a
  way to say "not now" without opening the show.
- Episode rows had five icons across a phone, where the useful ones were the hardest to hit.
  Three now, the same three.
- Long press on an episode plays it immediately. Tap still opens it.
- The show's name in the episode screen's top bar opens the show. It was sitting there doing
  nothing.

Extracting `rememberShownEpisode` kept EpisodeScreen inside its length budget -- the follow-the-
player logic is a named concern and reads better out of the way than the top bar did.

All four checked on a device, not just in tests: the three icons, a long press starting playback
at 0:06, and the title tap landing on the show.

The coverage gate pushed back and found one more gap on the way: a fetch failing with no message
fell through to the exception's class name, and nothing exercised it. Tested now. The exception
covers only the deleted method's own tests.

1065 tests, detekt 0, lint clean.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Refresh on any connection; leave the download guard where it belongs</title>
<updated>2026-11-08T20:38:55+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-11-08T20:38:55+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=65ae792d5393fbfcc304ac683f54f6fcdca10a2c'/>
<id>urn:sha1:65ae792d5393fbfcc304ac683f54f6fcdca10a2c</id>
<content type='text'>
Peter's mobile data is not metered, so the UNMETERED constraint on the refresh worker was doing
nothing for him -- and the question was whether to mark the connection metered or drop the guard.
Neither, quite.

The guard was never about the cost of refreshing. Its own comment said so: "refreshing every feed
is cheap, but downloads that follow are not". It was a proxy for a guard on downloads, and the
refresh is genuinely cheap -- every request carries If-None-Match and If-Modified-Since, so an
unchanged feed is a 304 with no body and a whole sweep is a few hundred bytes a show. Waiting for
Wi-Fi to spend that is what let a failed refresh from hours earlier stay on screen as the current
state of the library.

So refresh runs on any connection now, and the download worker keeps its unmetered and
battery-not-low constraints, which is where the expensive unattended work actually is.

Marking the connection metered would have worked too, and is worse: it is a system-wide setting
changed to compensate for one app, affecting every other app on the phone.

**What the guard was really hiding, and still is:** anything reaching the queue calls
Scheduling.downloadNow, which has no network constraint at all -- deliberately, because Peter
asked for things he queues to download immediately including on mobile data. An auto-queue rule
firing during a background refresh goes down that same path, so a rule can start tens of
megabytes unattended on cellular. That is fine while his data is unmetered and is the real hole
if it ever is not; distinguishing "I queued this" from "a rule queued this" is the fix, and it is
not in this commit.

1059 tests, detekt 0.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>A feed must not be able to spend the server's token</title>
<updated>2026-11-06T09:18:57+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-11-06T09:18:57+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=68a8d692f03ddc48aaf70399bfb1cc7584e21810'/>
<id>urn:sha1:68a8d692f03ddc48aaf70399bfb1cc7584e21810</id>
<content type='text'>
The security review of the last commit found a confused deputy in it, and it was right. The
bearer was attached on origin alone, and `episode.mediaUrl` comes out of a feed -- so a publisher
could set an enclosure URL on the skip server's own origin, match the origin check exactly, and
have the app issue an authenticated GET to a path of their choosing, storing whatever came back
as that episode's audio. Confirmed by writing the test before the fix: it failed.

Provenance is the first gate now and origin is the second. `authenticated = true` is passed only
for the URL `ServerAudio.resolve` returned, never for one that arrived in a feed, and the origin
check still has to agree -- so a server answer naming some other host is fetched without
credentials rather than sending them there. Both gates have a test, including the case where they
disagree, and OutboundRequestsTest pins that exactly one call site in the app is authenticated.

**Chasing the coverage gate honestly turned up a real bug**, which is the second time this week
it has earned its keep. Rather than file a third COVERAGE_EXCEPTION I went to see what the
uncovered lines in EpisodeDownloader actually were, and wrote a test for the empty-response
branch. It failed: OkHttp hands back an empty body rather than null for a 204, so the existing
`?: run { ... }` guard never fires, the copy writes nothing, and a **zero-byte file is renamed
into place and recorded as DOWNLOADED**. That episode then fails to play and reads as the
publisher's fault. The length is checked after the copy now, which catches a 204, an empty 200
and anything else that arrives with no bytes.

Also tested while there: the media-type to file-extension mapping, which had none. Not cosmetic
-- Media3 picks an extractor partly from the extension, so a .audio file holding mp4 bytes is a
plausible way to get an episode that will not play. Parameters and capitals included, because
feeds send "audio/mpeg; charset=utf-8" and "AUDIO/MPEG".

1059 tests, detekt 0, lint clean, coverage 94.09% -&gt; 94.28% with no exception.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
</feed>
