<feed xmlns='http://www.w3.org/2005/Atom'>
<title>listen.git/.coverage-baseline, 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-11T16:28:04+00:00</updated>
<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 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>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>
<entry>
<title>Send the server's token as a header, so the audio URL can stop carrying one</title>
<updated>2026-11-06T09:06:36+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-11-06T09:06:36+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=ba3ca37897c12d2d285f48e8a429d47b40773df5'/>
<id>urn:sha1:ba3ca37897c12d2d285f48e8a429d47b40773df5</id>
<content type='text'>
Groundwork for the server to stop putting the token in /audio/ query strings. Apache no longer
writes those to disk, but a secret in a URL still travels through everything that handles the
URL, and the only reason it was there is that the downloader had no credentials of its own.

Now it does: ServerAudio gains bearerFor(url), ServerSkips answers it from the configured
address, and downloadOverHttp attaches Authorization: Bearer when it gets one.

**The whole risk is in that one question.** This downloader also fetches episodes from whatever
CDN a publisher nominates, so an over-eager match would hand Peter's server credentials to every
podcast host he subscribes to -- strictly worse than the leak being closed. ServerBearer
therefore compares parsed origins, scheme and host and port, never the text of the URL, and the
test is mostly near misses: a host the server's name is a prefix of, one it is a suffix of, the
name appearing in a path or a query or a fragment, a userinfo section ending in the server's name
before an `@`, a downgraded scheme, a different port. Several of those pass a naive string match.

One of those tests needed a test of its own. `https://listen.terst.org@evil.example/...` is
refused, but it would also be refused if the URL simply failed to parse -- in which case the
origin check was never reached and the thing I thought I was testing was untested. So there is an
assertion that it parses, with host evil.example and username listen.terst.org.

The wiring test runs the real ServerSkips rather than a fake and checks the publisher path sends
no Authorization at all, because the unit tests above it fake exactly the component whose
host-matching is the safety property.

The server still emits the query token; this has to be installed before that stops, or downloads
401 in the gap. That is the next change, not this one.

1052 tests, detekt 0, lint clean, coverage 94.06% -&gt; 94.09%.

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 our copy too, and the transcript URL stops leaking the token</title>
<updated>2026-11-05T09:09:50+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-11-05T09:09:50+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=ca385bfac567697f050ccaa506f9fb27681750de'/>
<id>urn:sha1:ca385bfac567697f050ccaa506f9fb27681750de</id>
<content type='text'>
**Android Auto was the one caller still streaming from publishers.** `onAddMediaItems` resolved a
browse id straight to a media item without asking whether the audio existed, so an undownloaded
episode was handed to the player as its publisher's URL. That is the worst place for it: the car
is where most of this library is actually heard, and misplaced skips are least obvious there.

It goes through `PlaybackAdvancer.startFetchingIfNeeded` now -- start, and fetch first if there is
no file -- the same decision the in-app paths make, in the one class tested for it. A fetch that
fails stays failed rather than falling back: the publisher's copy carries different advertising,
so the server's measured breaks land in the wrong place, and silence that can be retried beats
skipping into the programme.

With that, nothing reached the fallback, so it is gone. `setUri(localPath ?: mediaUrl)` is now
`setUri(localPath)`, `MediaUrl` is deleted, and OutboundRequestsTest asserts zero remote media
items rather than pinning one. `start` also reports Gone for an episode whose show *and* file have
both gone, which is what made the fallback removable -- an orphan that still has its file plays.

**Two findings from the security review of the last commit, both mine, both real:**

- `Request.Builder().url()` throws on a URL it cannot parse and only IOException was caught, so a
  feed with a junk enclosure threw straight past the Result its caller folds. Survivable when this
  ran from a worker; not now that every play goes through a fetch, where one bad enclosure in one
  feed would stop any episode starting. Caught in the downloader, and `fetchAndStart` turns a
  thrown fetch into FetchFailed -- rethrowing CancellationException, which a test now pins,
  because swallowing it would report a cancelled start as a failed one and leave a false error on
  screen.
- `FeedFetcher` logged whole feed URLs. Private feeds carry a token in the query and some carry
  credentials in the userinfo. `FeedFailureText.redact` cuts them to scheme and host, and is
  tested as the security control it is rather than as tidiness. The unparsable branch logs neither
  the address nor the exception, and says why: that exception's message *is* the address, so
  logging the cause would put back exactly what redacting removes.

**The transcript URL no longer carries the token.** Apache logs the request line, so every
/api/transcript fetch wrote the server's token into its access log in plaintext, across fourteen
rotations. It was redundant there: the phone sends Authorization: Bearer anyway and tokenOK takes
either. The audio URL still carries one -- not redundant, the downloader has no credentials of its
own -- so removing that needs an app change installed before the server stops emitting it.

The gate refused this twice and was right both times. The coverage drop was not all deletion: the
redaction and the cancellation contract were genuinely untested, and have tests now. Then detekt
caught an import left behind by moving the redaction into FeedFailureText -- a move I made after
the last time I ran it.

1031 app tests, 376 server 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>Say what happened in words, and stop streaming from publishers</title>
<updated>2026-11-05T08:42:28+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-11-05T08:42:28+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=32df466c5c851c1a3e88c973627a9c07d2634e2b'/>
<id>urn:sha1:32df466c5c851c1a3e88c973627a9c07d2634e2b</id>
<content type='text'>
Three things, from one red banner on Peter's phone reading "Playback failed:
ERROR_CODE_IO_BAD_HTTP_STATUS". His words were "arcane and sketchy". Both halves were fair, and
the second was pointing at a real bug rather than a wording problem.

**The app was streaming episodes from their publishers.** `toMediaItem` falls back to the
enclosure URL when there is no local file, and nothing stopped a play reaching it: press play on
an undownloaded episode and it opened the publisher's CDN directly. Three things wrong with that.
It is the automatic publisher fallback the download path had removed on purpose in October ("Do
not reintroduce an automatic fallback for a transient failure"), surviving where nobody looked.
It is an outbound request to a third party that nothing asked for -- the sketchy part, and Peter
was right to read it that way. And worst, nothing in playback checks `audioFromServer`, so the
breaks the server measured on *its* 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.

`PlaybackAdvancer.start` now returns NeedsFetch for any episode with no file, which routes through
the download path that already honours the server rule. One caller still reaches the fallback --
Android Auto resolves a browse id straight to a media item without the advancer, and a car that
silently plays nothing is worse than a car that streams. That needs fetching inside the session
callback; until then OutboundRequestsTest pins the fallback to exactly one line.

Ruled out first, with evidence rather than theory: the skip server (every request in its access
log is a 200), the user agent (both the app's and Media3's get 200 from the enclosure), and my own
deploy the day before.

**The messages.** Media3's constant is now a sentence (`PlaybackFailureText`), and it was not the
only one. The same pass found `Download failed: ${it.message}`, `Could not read that file:
permission denied`, and `FeedFetcher` putting the literal word `UnknownHostException` under a
podcast's name in the library -- that one reaches two surfaces, because the text is also stored as
the subscription's `lastError`. **Two existing tests asserted the machine text came through**,
which is how it survived review; they assert the opposite now.

Accuracy turned out to be the harder half. "It will be retried" reads better than "Try the
download button again" and is a lie: a failed download is deliberately left FAILED. A 404 and a
503 both mean "it didn't work" to a library and are different decisions for the person choosing
whether to unsubscribe, so they say different things. Where the cause cannot be narrowed the
sentence stays vague rather than guessing. Every cause goes to logcat -- which detekt caught me
failing to do in FeedFetcher, three swallowed exceptions after I removed their messages.

**The guardrails Peter asked for**, both watched failing before being trusted:

- `PlainLanguageTest` -- no listener-facing message may be built out of an exception or spell out
  an error constant. Deliberately scoped to where errors surface rather than judging all prose.
- `OutboundRequestsTest` -- one OkHttpClient, built in AppGraph, carrying SecureSchemeInterceptor;
  nothing may open a connection another way; and the publisher-stream fallback must stay at one
  site. The same arrangement the server has in safefetch/audit_test.go and for the same reason:
  this app fetches URLs chosen by podcast publishers.

**And the readout, which Peter asked for directly.** The per-show "Detected skips" lines read the
confirmation rows the on-device detector used to write, so since detection moved they could only
ever say "Nothing found yet" -- on every show, including ones the server was publishing breaks
for. Counted off the episodes now. Verified on a device against a staged state rather than argued:
three of five episodes carrying a detected intro, one carrying an ad break, reading "Found in 3 of
5 episodes" / "None found in 5 episodes" / "Found in 1 of 5 episodes". *Not checked* and *checked,
found nothing* are different answers again, which was the whole point of the readout.

1016 tests, detekt 0, lint clean. Coverage 94.58% -&gt; 94.12%, COVERAGE_EXCEPTION for the same
reason as yesterday: the deleted confirmation-row code was covered.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Delete on-device detection. Detection is the server's</title>
<updated>2026-11-04T10:14:52+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-11-04T10:14:52+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/listen.git/commit/?id=9678aa3e980d1e44defb3808812291968565f869'/>
<id>urn:sha1:9678aa3e980d1e44defb3808812291968565f869</id>
<content type='text'>
Peter's call: server first. The audio-fingerprint subsystem ran on the phone until detection moved
to the server, and then kept being shipped: FingerprintWorker and MidrollWorker went at the time
and the twelve classes they drove stayed, unreachable, in every APK since. ReachabilityTest found
it on 2026-11-03 once it was widened to classes, and it has been sitting in that test's allowlist
with its reasoning since.

Gone: IntroOutroDetector, AudioCorrelation, MidrollScanner, MidrollDetector, MidrollUpdater,
FingerprintUpdater, MediaCodecClipSource, Decimate, Fft, MonoResampler, PcmCodec, StoredClip, and
the 67 unit tests over them. 3,092 lines.

**Recoverable by name, not by archaeology.** The tag `on-device-detection` marks the last commit
that has it, pushed to the local remote along with everything else from this session, and the tag
message says how to bring it back. The work cost weeks and the approach may be wanted again.

What stayed, because none of it is detection: SkipSummary words the readout, BreakTrack and
BreakPopup draw and explain the timeline's marks, and MidrollClipStore shrank to deleteAllFor
alone -- its write and read halves had one caller each and both were the deleted updater, but a
phone that ran the old version still has clip files on disk and unsubscribing is the only thing
that clears them. Its test now writes the old layout by hand, because that layout is a
compatibility concern now rather than this class's own choice.

IntroOutroDetector.CONFIRM_THRESHOLD and MidrollDetector.CONFIRM_THRESHOLD moved to SkipSummary,
the only thing left that reads them. Both were three and the server's DefaultConfirmThreshold is
also three, checked against each other before the move.

**The database was deliberately not touched.** show_fingerprints, show_midrolls and
episode_midrolls are the server's columns now -- ServerSkips writes its answers into exactly the
columns the on-device detector used to, which is why playback needed no changes when detection
moved. Four MidrollDao methods went because they were the discovery half (upsertCandidate,
deleteCandidate, getUncheckedDownloaded, setMidrollCheckedAtMs) and the reachability check named
them; a DAO method is not schema, so there is no migration here and nothing for a real library to
go wrong over.

Two things found while doing it, neither caused by it, both written up in CLAUDE.md:

- **The per-show "Detected skips" readout can never say anything.** It reads candidate and
  confirmation rows, and nothing has written one since detection moved -- the server writes
  per-episode ranges and offsets instead. Verified on the emulator, not inferred: a fresh install
  with a live subscription shows "Nothing found yet / Nothing found yet / None found yet" while the
  server is finding and publishing breaks for that very show. It has been lying since the
  migration; deleting the detector only makes it undeniable.
- **RetentionSweeper's pending-episode guard protects nothing**, for the same reason: nothing sets
  a pending basis any more, so both id sets are always empty.

The APK barely moves -- 110KB of dex, 72.61MB to 72.50MB -- because it is dominated by the
embedded Python. This was never going to be a size win and is not being sold as one; it is 3,092
fewer lines to read, search and mislead.

1004 tests pass, detekt 0, lint clean. Coverage 95.35% -&gt; 94.58%, which is the documented
COVERAGE_EXCEPTION case: deleting dead code that happened to be well covered.

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