| Age | Commit message (Collapse) | Author |
|
/audio/ URLs carry the token as a query parameter, because a URL handed to a downloader has to be
usable as it stands, and the "combined" log format logs %r -- the whole request line. So every
audio fetch wrote the token in plaintext into a file logrotate keeps fourteen copies of: 335 lines
across fifteen files, going back months.
Both vhosts now use a listen_noquery format, %m %U %H instead of %r, which keeps the method, path
and protocol and drops the query. Verified with a canary token against the live server rather than
by reading the config: the request logged as "GET /api/health HTTP/1.1" with nothing after it, and
a real request from the phone logged cleanly alongside it. The existing logs are redacted in place,
gzipped ones included, with their lines and timestamps intact -- zero occurrences left, and none
anywhere else under /var/log.
The token itself is worth rotating, since it sat in those files for months. That is Peter's call.
The real fix is for the audio URL to carry no token, which needs the downloader sending a bearer
header for skip-server hosts -- and only those, or the token would go to every podcast CDN it
fetches from. That app change has to be installed before the server stops emitting the query
token, so it is not in this one.
Config only; no code changed. Written up in CLAUDE.md so nobody restores "combined".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
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% -> 94.12%, COVERAGE_EXCEPTION for the same
reason as yesterday: the deleted confirmation-row code was covered.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
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% -> 94.58%, which is the documented
COVERAGE_EXCEPTION case: deleting dead code that happened to be well covered.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
The findings doc's last entry was 2026-10-19 and ended by naming what was missing: "a mechanism
that can tell a real short ad from a short coincidence". Words are that mechanism, it was built,
and thirty-two server commits later the doc never recorded what it measured. A future session
would have re-attempted the ad/furniture classifier that was already measured and deleted.
The new entry is derived from the live state file rather than quoted, which is how it caught that
publish.go's own figure describes a subset. "92% to 104% across eighteen episodes" is the
publisher-transcript number. Across the 24 episodes now publishing an ad span, recovery runs 20%
to 146%. Split by where the master came from, the spread stops being a spread: a publisher's
transcript gives 94-104%, a refetch gives a median of 33%. Nothing in Publishable looks at which
it has, and refetch masters are now fifteen of those twenty-four -- the majority of a tier the
design had decided not to act on.
It also caught me getting a conclusion wrong. Two Reply All episodes publish ad spans at 146% and
138% of their length gap, which reads as the detector over-claiming by half a minute of
programme. Looking at the show instead of the episode: Reply All's gaps run from -0.48 to +1.02
minutes across eight episodes. They straddle zero, so the feed's declared durations are not a
baseline for that show and the thing being exceeded is noise. The structural finding is the useful
one -- HasInsertedAdvertising asks only whether this episode's gap clears one ad unit, and has no
notion of whether the show's gaps are consistent enough to mean anything. Both versions are in the
entry, because the wrong one is the one a reader would reach for next time.
Nothing here changes behaviour. Whether a refetch should publish an ad at all is Peter's call and
is written up as such.
CLAUDE.md, separately:
- It still said the episode description shows about two lines. That was changed to ten yesterday,
for a reason that is recorded: the limit existed to stop long notes pushing the transport
off-screen, and the merge had already put the notes below the transport. Left as it was, the
doc was an instruction to reintroduce the bug.
- "207 unit tests" has been wrong for most of the project's life; the suite is past nine hundred.
Replaced with a note to ask the wrappers, since any number written here goes stale the same way.
- The TDD rule banned backfilling tests onto existing code without qualification, which also
forbids the one case where it is right -- pinning behaviour before moving it, so a refactor is
provably behaviour-preserving. Narrowed to say that, and to say the order matters. The sixteen
formatter tests in 2cc252a were written that way.
- Added the rule those formatter files were moved to satisfy: anything the app says out loud lives
in a named file of its own and is tested, never inside the composable that renders it. It was
already followed by eight files and stated only in WaitingForServerText's own comment.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
The commit hook had nine steps and not one of them touched the server. Every
server change for a week -- twenty-five commits, including the ad detectors and
the thing that publishes skips -- went in on somebody remembering to type "go
test" and reading the output. That is exactly the habit the Gradle wrappers
exist to replace, in the half of the repo nobody had wrapped.
go-verify reports from exit codes and parsed `go test -json`: gofmt, build, vet
and a real test count. It was watched catching both failures that look like
success in a terminal -- a failing test, named; and a package that fails to
compile, where go test exits non-zero with no failing test and the count
silently drops from 366 to 356.
366 is a number I did not have until now, because "go test ./... | grep -v ok"
cannot produce one.
It runs first in the hook, because ten seconds before the Android build beats
several minutes after it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
CLAUDE.md still described the server as doing audio detection and nothing else.
Six days of work later it recognises speech, subtracts transcripts, and
publishes the result as skips, and none of that was written down anywhere a
future session would look.
The device verification is the part worth recording most. Heavyweight's
"Jonathan + Death of an Artist", breaks at 0-1:45 and 13:32-15:16: downloaded
from the server, both rows landed in episode_midrolls, twenty seconds of playing
put the position at 2:03 -- which is only reachable by jumping the pre-roll --
and the post-roll skipped to the end, marked the episode played and reset the
position. Server to phone to playback, on hardware, not inferred from the fact
that each link has a passing test.
Also written down: the measured 10.3x realtime, the four alignment mistakes that
only measurement caught, why a declined transcript is kept rather than
discarded, why refetching belongs to slow-drip shows, and the shared-text signal
that is prototyped but not built.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Opening an episode now puts you in its transport at the stored position;
pressing play changes nothing about the layout. One primary control
(Play/Resume/Pause) with back/forward, a speed chip that opens the four
offered speeds (stored 1.5/1.75 still play and are named on the chip),
and everything secondary behind a top-bar overflow: queue, download,
played, watch, mark-older, chapters, and the publisher-copy action
(behind a dialog carrying its warning). Description shows about two
lines, the rest on tap.
The now-playing bar, library bar, queue, share receiver and nav graph
all open the playing episode's screen. PlayerViewModel stays the single
owner of playback state; no playback decision moved into the UI.
PlayerScreen.kt and WatchButton.kt are deleted. Coverage drop, if any,
is from removing covered dead code.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
The 12s floor cut real detection on shows with verified ads -- LeVar
Burton Reads fell from 19.0 to 10.5 minutes found against a 26.6 minute
gap -- so 8s looked like the fix. Measured on an isolated copy of the
live data it is not: Sleepy History at 8s finds 174 minutes of
"advertising" in a show that has 48 seconds of it, worse than the 4s
floor that produced Peter's original complaint.
MinRunSamples and MergeGapMs interact and cannot be tuned separately. A
low floor lets noise fragments survive and the 60s bridging then welds
them into enormous spans.
Had only LeVar been measured the floor would have been lowered.
Also re-pins .coverage-baseline, which the hook left modified after
d96c876 deleted covered readout code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
CLAUDE.md still described it as deliberate design with its original
rationale, which would have had a future session reintroduce the exact
behaviour Peter asked to remove. Also records the three cases that must
keep downloading directly, why a rejected token is permanent rather than
transient, and what audioFromServer is for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
The first full pass over all 42 feeds finished, then the server kept
re-deriving the same answers: Radiolab reported "53:29 of 59:39" four
times in one day. pollOne called detect unconditionally and the mirror
skips files it already has, so nothing had changed between passes. At
~6.5 minutes a show that is 4.5 hours a cycle against a two-hour poll
interval -- the machine never finished a cycle and never idled, burning
13h42m of CPU in 11h of wall clock, roughly 60% of it on the intro search
whose results are deliberately not published.
Show.DetectedBatch records the episode ids a completed pass examined.
The batch is the unit rather than the episode because every offset and
every break comes from comparing an episode against the others fetched
alongside it, so one arrival or one pruning legitimately changes all of
them. Order-independent, since feeds are not reliably sorted. Recorded
only on completion, so a cancelled or failed pass is retried.
Episode.DetectedAtUnix could not serve as the gate: it is only set for
episodes appearing in result.Offsets, so an episode nothing was found in
never got a timestamp and the gate would never close.
Also records what the first full corpus showed, including a disproven
hypothesis worth not retrying -- filtering breaks by partner count or run
length looks obviously right and makes agreement with the declared length
gap substantially worse -- and a correction: Sleepy History's 117 minutes
of "advertising" was a show-level sum sitting almost entirely inside the
two episodes its own guard already refused.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
The property that broke intro detection is the property that makes this work.
Eight episodes mirrored within one minute are maximally dependent, because the
ad server inserts whatever campaign is running into all of them identically.
For intro detection that destroys the independence the confirmation threshold
needs -- which is how it came to confirm an advertisement on 8 of 8 episodes.
For ad detection it is the signal.
And unlike intro detection it is checkable against a number the detector does
not produce: the feed declares the ad-free length, the file served is longer,
and the difference is the advertising.
**Measured on the live deployment, Radiolab's eight mirrored episodes: the scan
accounted for 53:29 of the 59:39 the length gap implies, with every individual
episode between 71% and 105%.** That is the first time detection in this
project has been checked against external ground truth and agreed with it.
The old sliding search could never have done this -- one full-length
correlation per window position, 199 seconds for a 240-second clip. BestLags
computes the cross-correlation over every lag in a single FFT pair, which says
where two recordings align; MatchedRuns then walks the extent of the agreement
directly, window by window, with no transform. Sized against measurements
rather than guesses: 2^22 is 1.69s, so a 70-minute episode decimated to 250Hz
is about five seconds a pair. Silence is explicitly not agreement, or separate
advertisements weld together across the pauses between them.
Breaks publish as ranges into episode_midrolls, which MidrollSkip and
PlayheadPolicy already read -- no playback changes and no migration. Intro and
outro offsets are still computed and logged but no longer published, and the
ad-derived ones already in the server's state were cleared.
Two guards publish nothing rather than something wrong: an episode whose
matches cover over 35% of it, and any single match over four minutes. A
re-aired episode or a two-parter would otherwise be eaten. Both record the
refusal, because "found nothing" and "refused what it found" are different
states and conflating them is the readout mistake over again.
Also three data-loss bugs, found by reading the journal after a restart rather
than by any test. The subscription list -- the one thing the server cannot
rediscover -- was persisted only when a whole poll cycle finished, which for 61
feeds is hours. A cycle re-applied its stale snapshot at the end, which would
undo a push that arrived during it and then delete the audio of every show the
snapshot omitted. And detection ignored cancellation, so systemd's 90-second
stop SIGKILLed the process before the save-on-exit could run. All three fixed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
The server is Peter's design and it works: listend polls the phone's feed
list, mirrors the newest 8 episodes per show, detects across all of them at
once, and serves the offsets *and the audio they belong to*. Deployed on
tungsten behind Apache at https://listen.terst.org. FingerprintWorker and
MidrollWorker are deleted -- the battery drain is gone because the work is.
Serving the audio is load-bearing. Dynamic ad insertion turned out to be on
every episode: Grass Apes declares 22:05 and the server was served 28:18. An
offset measured on one copy is minutes wrong for another, so the phone has to
play the bytes the detector examined. The offsets land in the same columns the
on-device detector wrote, so playback needed no changes and there is no
migration.
**The headline finding is a failure, and it is proved rather than suspected.**
Detection reported the intro confirmed on 8 of 8 Radiolab episodes and passed
its self-check. It had locked onto an advertisement. The chosen segment was
searched for in an ad-free copy of the same episode fetched hours earlier:
r = 0.0621. It is in the ad break, not the programme.
The confirmation threshold assumes three *independently fetched* episodes.
Mirroring 8 within one minute from the same ad server makes them maximally
dependent -- whatever campaign is running is in all of them, identical -- and
SelfCheck cannot catch it, because the segments genuinely are the same audio.
Moving to a server fixed the cost problem and broke the independence the
safety mechanism rests on. The published offsets are head-window matches, so
they happen to skip pre-roll advertising, which is useful by accident and will
move whenever the campaign rotates. It is not intro detection and is not
described as such anywhere.
The fix, not made: record when each episode was fetched and require the
agreeing episodes to span more than one fetch occasion. Reasoning, and an
argument for detecting the ads directly instead, in docs/detection-findings.md.
Also recorded there: two tooling traps that each cost real time. `android-ui
tap` can land on the soft keyboard, because the dump reports layout
coordinates the IME does not move -- a Save button and a text field both
appeared tapped and neither fired. And the server's token is alphanumeric now
because `adb shell input text` ate the leading "-" of a base64url one and
produced a silent 401 that looked like broken wiring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Peter's report: the phone runs hot, uses a lot of battery, playback takes a
moment to start, and intelligent skipping has never done anything.
**The hot phone and the dead detection were one bug.** The candidate search
correlated 117 window positions against the whole of the other clip,
re-transforming that unchanged clip every time -- 199 seconds per episode end,
measured on a desktop, so minutes more on a phone. FingerprintWorker is a plain
WorkManager worker, Android stops one at ten minutes, and nothing was persisted
until a comparison returned. So it was killed mid-flight, saved nothing, and was
re-enqueued to start over, forever. IntroOutroDetectorScaleTest is the tripwire
whose absence allowed this: the unit tests ran on 300-sample clips while
production ran on 1,920,000.
Fixed coarse-to-fine, plus AudioCorrelation.prepareTarget so the target clip is
transformed once per search rather than 117 times. 10.6x, and a test asserts the
chosen reference window is byte-identical to what a full-resolution search picks.
**Midroll detection could never have run at all.** decodeFull buffered a whole
episode at its native rate -- 434MB for a 13-minute episode, no largeHeap -- to
produce 6MB of 8kHz mono. It threw OutOfMemoryError into a swallowing
runCatching and the episode was then marked checked forever. MonoResampler now
streams the decode so the peak allocation is the output, and maxDecodableMs skips
a longer episode before spending anything and without marking it checked.
**Detection is moving to a server**, Peter's call, in Go to match podkit and
doot. server/ holds the ported core, verified three ways because this is the
second port of this maths and the first shipped a normalization bug: numpy's
literals extracted mechanically from AudioCorrelationTest, the FFT against a
direct DFT, and the whole correlation path against a brute-force Pearson.
On four real Radiolab episodes it confirms the intro on 4 of 4, at 48.0s, 8.65s,
8.65s and 65.15s -- the varying pre-roll, observed in working code at last.
skips.SelfCheck verifies located segments against *each other* rather than
against the reference, which is a different claim: correlation is not transitive,
so two segments can each clear 0.6 against a reference and be unrelated. It
rejected the outro on its first real run (r=0.746), withholding offsets that
would have ended three episodes early. Offsets are published only when they
verify.
Also: the episode screen gets a working scrubber with ad-break marks and a
declared-versus-measured length readout, queue rows open the episode instead of
doing nothing, PrepareTrace logs playback-start timing against episode length
for the still-open pause question, and -PphoneOnly drops the emulator's CPython
from a sideload build (114MB -> 99MB).
Full history and numbers in docs/detection-findings.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Peter's call (option 1 of the three in docs/detection-findings.md): exempt
an episode retention would otherwise reclaim while it is a show's pending
comparison basis, rather than extending the grace period or accepting the
gap.
RetentionSweeper gains a pendingEpisodeIds seam (suspend () -> Set<String>,
defaulting to empty) and filters its reclaim candidates against it before
the grace-period policy ever sees them -- every existing caller and test
was unaffected by the default. RetentionWorker wires the real thing: the
union of two new, narrow DAO queries --
ShowFingerprintDao.getAllPendingEpisodeIds() (intro/outro) and
MidrollDao.getAllMidrollPendingEpisodeIds() (midroll, reading
SubscriptionEntity.midrollPendingEpisodeId).
Applied to both fingerprinting mechanisms, not just the one that got
diagnosed: MidrollUpdater's discovery has the identical rolling-pending-
episode shape and would race retention the same way for a fast show, just
silently, with no frozen row to reveal it.
Not yet re-verified on a device -- the next live test is two Up First
downloads close enough together to actually compare, confirming
pendingEpisodeId advances to a real second episode instead of restarting.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sj3CoWEedM56bebByKgNxA
|
|
The fingerprinting runs on a device now and has still confirmed nothing for
a real show. That is an open question, not a known bug, and answering it
needs the prototype rather than the app -- only the prototype can show what
a parameter change costs in false positives, which is the number that
matters here.
docs/detection-findings.md collects what this round established: that the
pipeline works, that the confirmation threshold earns its place, and the
finding that reframes the problem -- dynamic ad insertion means the same
Grass Apes came back 29:34 on one fetch and 27:39 on another, so ad content
and the offset of everything behind it differ per request. It lists the
experiments in order, including using insertedAdMs as ground truth for the
midroll scan, and what not to touch.
Linked from the known-gaps list, which is where a future session looks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
**The hand-off.** Sharing a timestamped YouTube link now opens Listen on the
player, already playing at that point. Two things were missing. The
timestamp was only honoured on first insert -- the insert ignores a clashing
guid, so every share after the first silently did nothing and playback
resumed wherever Listen had last left it, which is exactly the going-back-
and-forth case. And nothing started playing; the video landed in the library
to be found. Re-sharing now moves the position and unplays the episode,
because sharing a moment in something you finished is how you go back to it.
A share with no timestamp still just adds it quietly -- interrupting
whatever is playing would be rude.
**A regression in yesterday's duration fix, found by following Peter's own
observation.** He noted the 22:05-vs-29:34 gap was probably content versus
ads. It is -- and that has two consequences I had missed.
The first is that `EpisodeMatcher.mergedWith` rewrites `durationMs` from the
feed on every refresh, correctly, because it is feed metadata. Writing the
measured length into that column meant the correction survived until the
next refresh and then silently reverted. So there are two columns now:
`durationMs` stays the feed's claim, `actualDurationMs` is what the player
measured, and `playbackDurationMs` prefers the latter.
The second is that the wrong number was never only cosmetic. It sizes the
decode buffer a full-episode midroll scan allocates -- truncating the scan
by minutes on precisely the shows that have midrolls -- places the outro
offset, and drives the finished check, which would mark a 29-minute episode
played at 21 and advance the queue. Those all read `playbackDurationMs` now.
And the third thing, which is Peter's idea and free: the difference between
the two numbers is the advertising in the episode, with no audio analysis at
all. `insertedAdMs`, shown on the episode screen, and a check on the midroll
detector -- whatever it eventually finds should add up to about that much.
Measuring it turned up something worth knowing: the same Grass Apes came
back 7:29 longer than declared on one fetch and 5:34 on another. Insertion
happens per request, so this is a property of a file, not of an episode.
Verified on the emulator. Hand-off: cold start into the player at 12:39 from
a stored 12:30, and the warm onNewIntent path at 25:14 from 25:00. The
YouTube resolver itself cannot be exercised here -- this host gets a 302 bot
page for every watch URL, as CLAUDE.md already records -- so the new code was
driven through the intent it receives, with the resolver's own behaviour
unchanged. Durations: list showed 22m on a fresh subscribe, 27m after
playing, with 1325000 and 1659454 stored separately, and the episode screen
reading "Ads inserted ~5:34". 550 unit tests, 12 migration tests on device,
detekt and lint clean. Coverage 92.96% -> 93.02%.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Two from Peter, one of which turns out to matter more than it looks.
**The list said 22m, the player said 29:34.** The feed is lying, not the
parser: Radiolab publishes Grass Apes as `00:22:05` and the file ExoPlayer
opens is 29:34. Checked against the live feed rather than guessed. The seven
and a half minutes between them are dynamically inserted advertising -- the
feed declares the ad-free content length and the CDN stitches in the rest --
so the error is large, systematic, and worst on exactly the shows the
midroll detector exists for.
The player is the first thing in the app that sees the truth, so
PositionWriter now writes it back, through a new narrow PlaybackStateDao.
Once per episode rather than once per tick: this runs on the position timer
for the length of an episode, and a lookup on every tick is precisely the
per-episode work that does not scale. A two-second disagreement is left
alone, being a container arguing with an encoder rather than a lie, and an
unprepared player reporting nothing never wipes what the feed said.
PlaybackStateDao is a split by meaning, not by count -- it owns the two
things playback learns and writes back, the only writes in the app whose
loss is unrecoverable. It also keeps EpisodeDao under detekt's interface
limit, which the new query had pushed it over; the config's own comment
argues against splitting a DAO to satisfy a count, so this splits it for a
reason that would stand on its own.
**Tapping the notification opens the app**, via setSessionActivity. It had
none, so the transport buttons worked and the body did nothing, which reads
as breakage rather than omission.
Verified on the emulator against the real feed: Grass Apes stored as
1325000ms on subscribe, 1770553ms after twenty-five seconds of playback.
The notification now carries `contentIntent=PendingIntent{... startActivity}`
in dumpsys -- I confirmed the intent is attached and targets this app's
activity, but did not simulate the physical tap. That same dump corroborates
what I told Peter about the notification being Auto's doing and not ours:
importance=2, sound=null, vibrate=null, category=transport.
ExoPlayhead joins the coverage exclusions. It is four one-line getters over
ExoPlayer that happen to live in PlaybackService.kt, so it read as covered
by that entry while actually being measured -- it is the seam that makes
PositionWriter testable, not logic. 540 unit tests, detekt and lint clean.
Coverage 92.73% -> 92.96%.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Four things Peter asked for, plus a crash found while verifying them.
- Bedtime shows. A per-show flag (schema 8->9) that takes a show out of the
library entirely; the moon icon in the top bar switches to listing only
those. Not a category system -- the one grouping worth building is the one
with behaviour attached, which is that entering bedtime mode also arms the
sleep timer. BedtimePolicy owns the two cases that make that safe: it will
not overwrite a timer set by hand, and leaving only cancels a timer bedtime
itself armed. The mode is deliberately not persisted; a restart is morning.
- Shows with nothing waiting are dimmed. Same condition the library sort
already used -- nothing unplayed and unqueued -- now returned alongside it
as SubscriptionListing.hasWaiting. That comes from a COUNT rather than the
sort key on purpose: an episode with no publish date ranks at 0, which is
right for ordering and would have greyed out a show with one sitting
unheard. LibraryOrderTest pins that case. The lastError line keeps its
error colour through the dimming, since a failing feed looks exactly like
a feed with nothing new.
- Per-show controls moved into an overflow menu and a settings sheet, and
the tutorial prose is gone. Reaching the episodes used to mean scrolling
past drip cadence and two paragraphs; Peter wrote the requirements and
does not need them explained back. Unsubscribe went into the same menu,
off the top bar where it sat one tap from Refresh.
- Skip detection is visible at last, per show and per episode. SkipSummary
is pure and its wording is tested, because the distinction that matters is
confirmed-and-acting versus still-gathering, and "not checked yet" is a
third state again -- midroll scanning runs nightly, so a fresh download
legitimately has nothing to say yet.
And the crash. Opening the new readout on a device threw
SQLiteBlobTooBigException, which turned out not to be the readout's fault:
show_fingerprints.pendingClipPcm held a whole decoded head/tail window --
240s at 8kHz is 3.84MB as 16-bit PCM -- and Android reads every Room row
through a CursorWindow that refuses anything over 2MB *on the way out*. The
insert succeeded and the next read threw. Intro/outro detection therefore
died the moment a show's second episode was fingerprinted and could never
have worked as shipped; nothing off-device could have caught it.
Migration 9->10 drops the column and keeps only pendingEpisodeId, re-decoding
on demand -- exactly what midrollPendingEpisodeId already did for the same
reason. StoredClip now owns the limit, MidrollUpdater caps a candidate
segment at 60s before persisting it (a long sponsor block would hit the same
wall) and refuses anything that still would not fit, and StoredClipSizeTest
pins both against the limit so the class cannot come back quietly. Trimming
under-skips a very long break rather than over-skipping into content, which
is the safe direction.
Verified on the emulator: 10 migration tests including the never-before-run
7->8, LibraryOrderTest, and the whole reported flow by hand -- bedtime split,
timer arming and cancelling, the dimmed row, both readouts. Re-ran the exact
sequence that crashed: three downloads, no crash, FingerprintWorker SUCCESS,
and the MP3 decoder visibly running, which is the first time
MediaCodecClipSource has executed on hardware at all.
Still open, and recorded in CLAUDE.md rather than glossed: detection has not
actually confirmed an intro or outro for a real show. Three Up First
downloads left both ends "Nothing found yet". Possibly correct -- the
threshold is strict by design -- or possibly the 8-second reference window
against a roughly 6-second sting. That wants the desktop prototype, not a
guess at a tuned constant.
Coverage 91.76% -> 91.84%.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Desktop-prototyped first, same discipline as the intro/outro half: three
Darknet Diaries episodes spanning 2026 and one from 2017 were fetched fresh
and cross-correlated. Beyond the show's fixed ~51s intro (present unchanged
across all nine years), the same coarse-to-fine scan found a second, shorter
segment recurring at a *different* timestamp in every episode's body and
independently present in the 2017 episode too -- a real recurring house
promo, not a coincidence of two fetches landing close in time.
That two-episode, different-position, still-present-nine-years-later shape
is exactly what a midroll break looks like and exactly what IntroOutroDetector
was never built to find: it only ever compares fixed head/tail windows.
Midroll detection means scanning a whole episode body, and a show can have
several distinct breaks at once, not one fixed slot per end.
- MidrollScanner: pure, single-resolution "find every segment two recordings
share, wherever each has it" -- deliberately position-agnostic, built on
the existing AudioCorrelation primitive rather than a new one.
- Decimate: block-averaging downsample, the coarse half of a coarse-to-fine
split. A brute-force native-rate scan across a full episode measured
30-160s per episode *pair* on a desktop CPU in the prototype; MidrollUpdater
runs MidrollScanner once on ~8x-decimated audio to find candidates cheaply,
then re-verifies each at full resolution with a single AudioCorrelation
call -- both for a trustworthy score and to reject anything the decimation
made look like a match but isn't.
- MidrollDetector: IntroOutroDetector's confirm/discard state machine,
generalised from one candidate per show to a list. A candidate an episode
doesn't confirm is discarded outright, same discipline, same reason: the
Darknet Diaries trap applies here too.
- MidrollUpdater: orchestrates discovery and confirmation per show. Discovery
compares the show's single rolling "pending" episode against whatever is
newest; unlike ShowFingerprintEntity's pendingClipPcm, no audio is stored
for it between runs -- a pending *whole episode* is tens of megabytes of
PCM, not the few seconds an intro/outro clip costs, so the file is
re-decoded from disk on demand and the pending slot just moves forward if
it's since been reclaimed by retention. A real bug the tests caught: the
same segment surfaces again every time the pending pointer rolls forward,
which without dedup became a second row for content already tracked.
Fixed by recognising a fresh discovery hit as already-tracked by *where*
it landed in the new episode (MidrollDetector.allMatches), not by
re-correlating extracted clip content, which two different coarse passes
don't reliably extract the same sub-range of.
- Storage: AppDatabase 7->8, a show_midrolls table (one row per candidate,
not per show -- several breaks can be tracked at once) and an
episode_midrolls table (the confirmed ranges detected per episode).
EpisodeEntity gains midrollCheckedAtMs (per-episode, not a show cursor, so
an out-of-order backfilled download still gets checked). SubscriptionEntity
gains midrollPendingEpisodeId.
- Playback: MidrollSkip mirrors AdChapters' shape exactly. PlayheadPolicy
checks it unconditionally, same as intro/outro -- no per-show toggle, the
confirmation threshold is the safety mechanism. PlaybackSources grows a
midrolls() method alongside chapters() rather than threading a new DAO
through PlaybackAdvancer's constructor, which would have pushed it over
detekt's LongParameterList threshold for no real reason.
- Trigger: MidrollWorker runs nightly while charging, not per-download like
FingerprintWorker -- a full-body scan is too expensive to run eagerly, and
unlike a download it never touches the network, so there is no wifi gate
either. Peter's call.
MediaCodecClipSource gains decodeFull, sizing the decode buffer from
durationMs with a margin rather than the fixed 4-minute clip window
decodeHead/decodeTail use.
Coverage 91.70% -> 91.76%. Neither half of Tier 3 has run on a device this
session -- see CLAUDE.md's known-gaps list.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sj3CoWEedM56bebByKgNxA
|
|
Peter asked how expensive it would be to check before dropping them. Checking
turned out to be cheaper than arguing about it, so here is the answer from his
own 61 subscriptions rather than from general knowledge.
Every feed's newest episode was fetched far enough to read its entire ID3 tag
-- the declared size from the header, not a guessed window, which matters
because several are multi-megabyte (Cruising the Waves carries 10MB of cover
art) and a first pass with a 400KB window left thirteen of them inconclusive.
ID3v2.2's 3-byte frame headers needed handling separately from v2.3/2.4's
4-byte ones, which is what made Dan Carlin's feeds look truncated at first.
Not one of the 61 carries a CHAP frame. The frames present are all artwork and
metadata: APIC, TALB, TIT2 and friends. Exactly one show publishes chapters at
all -- Wards Way Podcast -- and it does so as Podcasting 2.0 JSON, which has
been supported since the skip work landed.
So CHAP is dropped rather than deferred. The two survey scripts are kept under
tools/ so this can be rerun in a minute if a new subscription ever suggests
otherwise, rather than re-derived.
Two things fell out of the survey worth writing down. A feed can carry two
chapters elements per item -- the podcastindex one with a url and a podlove one
without; Buzzsprout emits both. The parser takes the first non-null url so it
is unaffected, but a last-wins read would silently lose chapters. And the one
show with chapters marks none of them toc:false, so ad-chapter skipping -- on
by default as of this morning -- currently has nothing to act on anywhere in
the library. It is correct and idle, waiting for a publisher who marks breaks.
Documentation and tooling only; no behaviour change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Two of Peter's asks.
Played episodes are hidden by default. An episode you have heard is done with,
and leaving it in the list buries the ones that are not. A global toggle on the
show screen brings them back, so a mis-tapped "played" can still be found --
stored in DataStore, which has been a declared dependency since phase 1 with
nothing using it. Android Auto always hides them regardless of the toggle: a
car list is scrolled blind at a traffic light.
Hand-off carries the position both ways. Out: "Watch from here" opens the video
on its own site at the point reached. In: a link shared from YouTube with a
timestamp on it was shared at that moment deliberately, so the episode starts
there rather than at the top. WatchLink does both and is pure, because an
unrecognised URL shape fails silently -- as a link that simply starts from the
beginning -- and YouTube produces a lot of shapes.
The button sits on the episode screen as well as the player. That was not the
first design, and testing changed it: the player-only version is unavailable
exactly when it is most wanted, because an episode YouTube declines to serve
never starts playing and so never reaches the player at all.
Verified on a device, round trip: youtu.be/dQw4w9WgXcQ?t=90 shared in stored a
position of 90000, the episode screen offered Watch, and tapping it fired
youtube.com/watch?v=dQw4w9WgXcQ&t=90s. Marking an episode played removed it
from the list, the toggle brought it back, and the preference survived a
restart. An ordinary podcast offers no hand-off, having no page to open.
Two things caught on the way. A crash: the episode cache was declared after the
init block that collects from a StateFlow, which emits synchronously, so it was
still null on the first emission -- caught by the launch check, now a plain
field declared with the others. And the played switch needed a contentDescription
distinct from its visible label, or it is ambiguous to pick -- the same trap the
drip switch hit.
Preferences joins the coverage exclusions: a Context, a file and a generated
Flow, deciding nothing. WatchLink is fully covered.
No permission changes, no schema change. 442 unit tests green, detekt and lint
clean, coverage steady at 90.13.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Two things.
Ad-chapter skipping is on by default, at Peter's request after living with it.
It shipped off out of caution, being the one part of the skip feature that
removes audio nobody has heard -- but it only ever acts on sections a publisher
explicitly marked as not content, never on a guess from a title, and doing that
without being asked each time is the point. Migration 5 -> 6 moves existing
shows rather than leaving the new default to apply only to new ones.
The column's SQL default stays 0 while the Kotlin default becomes true. Room
writes every column on insert, so the SQL default is only ever consulted by an
ALTER TABLE that has already happened; changing it would mean recreating the
table for a value nothing reads. Said so at the field.
Two unit tests were leaning on the old default to mean "not opted in". They now
say `skipAdChapters = false` explicitly, which is what they meant.
Second: enableAndroidTestCoverage is gone again, a day after adding it. It was
the "both" half of the coverage change, and measuring it made the trade
obvious -- it instruments every class in the debug build, which cost 25MB on
the APK (118MB -> 143MB) plus runtime probes, on every sideload, for a report
that would be run rarely. I had already published one of those builds. The
framework-adapter exclusions carry this on their own; they are what moved the
number from 65% to 90%, and what makes a future drop mean something.
No permission changes. 428 unit tests green, 6 migration tests on a device,
detekt and lint clean, coverage 89.97 -> 90.12.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Facebook serves no og: tags to a non-browser at all, so a shared video was
filed as "Video 10153231379946729". yt-dlp knows perfectly well what it is
called, and is going to be run against that URL anyway.
The probe only happens when the page gave no title: a title that was shared is
the one that was meant, and this costs a network round trip. It is allowed to
fail, because it buys a nicer name rather than anything the episode needs --
a video that cannot be probed still downloads and plays.
Duration comes along with it, which Facebook episodes previously had to do
without entirely.
This retires the last entry but one from the reachability allowlist:
YouTubeAudio.probe has been sitting there since phase 1 described as "the
resolver will want it for episode durations", which turned out to be exactly
right.
Verified on a device: the same Facebook video that arrived as
"Video 10153231379946729" this morning now arrives as "How to Share With Just
Friends", 74 seconds.
No permission changes, no schema change. 428 unit tests green, detekt and lint
clean, coverage 89.93 -> 89.97.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Peter's call. The gate had been overridden eight times running, every one a
class whose whole body is a call into ExoPlayer, Media3, Room, WorkManager or
Chaquopy -- none of which execute on the JVM.
The last few overrides were the argument for doing this. Pulling completion
decisions out of PlaybackService into tested classes lowered the number,
because the tested part was already counted and the glue it left behind was
counted too. Writing an instrumented test for a migration lowered it again,
because jacocoTestReport measures unit tests only. A metric that punishes both
of those is measuring how much Android glue the app contains, not how well its
logic is tested.
Same code, 65.04% -> 89.93%: 629 uncovered lines were adapters, 131 are real.
What is left is honest -- page-fetch coroutines that need the actual host, a
few data classes, and Room's Converters, which are plain Kotlin and genuinely
untested. A drop from here will mean something.
The list sits next to its reasoning in the build file, with the bar written
down: "cannot execute off a device", not "awkward to test". QueueRepository,
PlaybackAdvancer, EpisodeDownloader and ChapterFetcher all touch
framework-adjacent things and all stay measured.
Also added jacocoFullReport, which folds in instrumented coverage for when you
want the fuller picture with a device attached. The commit gate deliberately
does not read it: device data only exists after a device run, so including it
would make the gate report different numbers for identical code depending on
whether one happened recently -- worse than a narrow gate.
One trap worth recording: Kotlin block comments nest. A KDoc mentioning a glob
with `/*` in it opened a nested comment, so the closing `*/` only closed the
inner one and the comment ran on until it hit a `*/` inside a later string
literal -- producing syntax errors on innocent lines well below the real cause.
No behaviour change. 425 unit tests green, detekt and lint clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Individual videos, which is the whole of what is reliably possible: Facebook
pages publish no RSS, so there is no equivalent of subscribing to a YouTube
channel. Flagged that before building rather than quietly delivering half.
A shared link is recognised across the shapes Facebook actually produces --
watch, page videos with or without a slug, reels, fb.watch and /share/v codes,
on www, m and web hosts -- and lands in its own "Saved Facebook videos"
container. Its own, because the container carries the kind that decides how its
episodes are fetched, and one row cannot be honest about two sources. The guid
is prefixed per source too, so a Facebook and a YouTube video that happen to
share a numeric id stay separate episodes.
The og: tag parsing YouTube already did privately is now OpenGraph, used by
both, per the standing instruction about two solutions to the same problem.
It also learned to tolerate either attribute order and single quotes, which
real pages use.
The download seam stops claiming to be YouTube's: yt-dlp takes a URL and works
out the site itself, which is exactly what made this cheap. YouTubeDownloadSource
is ExtractedAudioSource and ChaquopyYouTubeSource is ChaquopyAudioSource.
One real find, and the reason the first attempt failed on a device: Facebook
publishes no audio-only stream at all, only pre-muxed sd/hd mp4, so
"bestaudio[ext=m4a]/bestaudio[ext=webm]/bestaudio" resolved to nothing and
yt-dlp reported "Requested format is not available". _FORMAT now falls back to
a muxed mp4. That is still a single already-complete file, so the no-ffmpeg
rule holds -- what must never appear there is bestvideo+bestaudio. Media3 plays
an mp4's audio track without complaint, and the data model has been
media-agnostic since phase 1 for this sort of reason.
Verified end to end on a device against a real public Facebook video: resolved
to its own container with the right kind and guid, fetched by yt-dlp, and
played. This also stands as the first proof of the previous commit's
fetch-then-play path against a genuine extractor rather than a direct URL,
since YouTube itself is bot-blocked from this machine.
Known gap, not fixed here: Facebook serves no og: tags to a non-browser at all
-- checked with the app's own user agent -- so a shared video shows as "Video
<id>". yt-dlp knows the real title, and YouTubeAudio.probe is still sitting
unused in the reachability allowlist waiting for exactly this. Small follow-up.
No permission changes, no schema change. 425 unit tests green, detekt and lint
clean, coverage 64.90 -> 65.18.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Marking an episode played and moving the queue on lived in PlayerViewModel,
which is scoped to the activity and releases its MediaController in onCleared.
So with the activity gone -- swiped off recents, or reclaimed under memory
pressure -- the audio carried on from the foreground service while nothing
marked anything played and nothing advanced the queue. Background listening with
the screen off is the normal case for a podcast app, so this was broken in the
case that matters most. The outro offset and ad-chapter skipping added last
commit rode the same tick and had the same hole.
Completion now belongs to PlaybackService, which is the component that survives
backgrounding. Two new tested classes carry the thinking: PlayheadPolicy decides
what a tick means (finish early on an outro offset, seek past a marked break, or
nothing -- with finishing beating skipping, or a show with both would seek into
the credits it was told to skip and sit there), and PlaybackAdvancer owns
finishing, advancing, and working out where the next episode starts. Neither
knows about Media3, so both are testable off-device; the service turns their
answers into player calls.
Where an episode starts is now decided in exactly one place. PlaybackAdvancer
.start is used by the service when the queue moves on and by the view model when
you tap Play, so an episode begins in the same position however it got there.
PlayerConnection no longer carries its own copy of the resume rules.
PlayerConnection deliberately stops handling STATE_ENDED and PlayerViewModel
loses onFinished entirely -- with both in place the queue would advance twice.
The view model now follows whatever is playing rather than only what it started,
so the player screen's show, speed and chapter list stay right when the service
moves on by itself.
Verified on a device with "don't keep activities" enabled, which destroys the
activity the moment it is backgrounded: an episode was resumed, the app sent to
home with no activity records left, and the service took the episode to its
outro line, marked it played, dropped it from the queue and started the next
one, which went on playing.
Also isolated the data loss seen twice this session: connectedDebugAndroidTest
uninstalls the app when it finishes, taking the database with it. A plain adb
install -r left four episodes in place and the next instrumented run left
"run-as: unknown package". Not a migration fault, and now written down as such.
No permission changes, no schema change. 385 unit tests green, detekt and lint
clean. Coverage 66.23 -> 65.33 with an exception recorded: the logic moved out of
the coverage-excluded ui/** tree into the counted service, and the ExoPlayer glue
that remains cannot run off-device.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
The last of Phase 2's three tracks, and the part Peter asked for in place of the
audio fingerprinting, which stays deferred.
Two tiers. Manual per-show offsets start each fresh episode past the intro and
treat it as finished before the outro; resuming something part-heard still lands
where it was left, and an offset longer than the episode is dropped rather than
obeyed, because someone typing minutes where seconds were meant should not have
a show start at the end. The fields have been on SubscriptionEntity since phase
1 with nothing reading them.
Chapters are the exact tier: where a show publishes a Podcasting 2.0 chapter
file, the player lists it and taps seek. Breaks the publisher marked `toc:
false` can be skipped, opt-in per show. Nothing is guessed from chapter titles
-- a show discussing its own sponsorship deal has a chapter called "Sponsors"
that is the actual programme, and a skip feature that eats an episode is worse
than none. A break whose end cannot be worked out plays rather than throwing the
playhead somewhere arbitrary.
Two real bugs surfaced while verifying this on a device, both older than this
work:
A start position handed to `seekTo` after `prepare()` is silently dropped across
the MediaController-to-service boundary. The intro offset began at zero, and so,
all along, did every resume -- an episode left half-heard restarted from the
top. Nothing failed and nothing logged. It now goes to `setMediaItem`, verified
on a device both ways: a fresh episode starts at the offset, a stored position
resumes at 150s and plays on from there.
Finishing an episode did not stop playback when the queue was empty, because
until now the file had always ended anyway. With an outro offset the episode
finishes while audio is still playing, so it marked the episode played and then
played the credits regardless.
Schema v3 -> v4: the chapter file cached per episode, and the per-show opt-in.
The migration test now covers 1 -> 4 straight through and asserts the opt-in
arrives off -- defaulting it on would have started removing audio from every
show at once.
Per-show settings moved to their own view model. Drip, the archive fetch and
both skip settings are one concern used by one screen, and ListenViewModel had
become the entry point for the library, the queue, episode actions and these as
well. It also gets its own busy flag: fetching an archive and refreshing a feed
are different operations, and one spinner for both had the top bar claiming a
refresh was running when it was not.
Verified on a device against a real feed throughout: a 60-second intro offset
starts the episode at 60s, an outro offset finishes it early and stops the
audio, and a seeded chapter file with a five-second marked break plays 0-5s then
jumps to 45s with the chapter list rendering alongside.
No permission changes. 364 unit tests green, detekt and lint clean, coverage
65.30 -> 66.23.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
The second of Phase 2's three tracks. A show can be set to release its back
catalogue on a cadence -- one a day, oldest first, by default -- and while drip
is on the show lists only what has been released rather than the raw feed. A 600
episode archive then reads as a show you are keeping up with instead of a wall.
A release always enqueues, and deliberately does not go through the rule engine.
Drip decides when you see an episode and the rules decide what happens to one;
running a released episode through the rules as well would let a rule silently
cancel a release, which reads as the drip being broken.
Three pieces of arithmetic worth keeping, all in DripSchedule and all tested:
The clock advances by whole intervals rather than to now. Moving it to now
discards the part-interval already elapsed, and a six-hourly worker against a
daily drip loses hours every day until "one a day" means one every day and a
bit.
A long absence is capped at ten and the excess is forgiven rather than carried.
A month away owes thirty episodes; handing those over empties the archive into
the queue, which is the thing the feature exists to avoid. Carrying the debt
instead would just pay it out as a capped burst on each of the next three runs.
Switching drip on settles what it already owes: everything played or already
queued is released up front, because hiding episodes you have heard makes a show
followed for a year look like it has three, and hiding one mid-queue strands it
where nothing can reach it. Re-enabling starts the cadence from now rather than
paying off a debt accrued while it was off.
Visibility is a query parameter, not stored state, so switching drip off
restores the whole catalogue without touching a row. Android Auto browses the
same set. Feed reconciliation deliberately still sees everything -- matching
against the visible set alone would read a held-back episode as missing and
re-insert it on every refresh.
RFC 5005 archive paging is what gives a drip anything to drip: FeedArchiveWalker
follows prev-archive pages, resolving relative hrefs against the page that
carried them, and stops on a loop, a page cap, or a fetch failure -- keeping what
it collected and reporting why, because half an archive is useful and a silent
half surfaces months later as "why did this show run out?".
Two gaps found while wiring it up, both fixed here: the subscription screen had
no snackbar at all, so a refresh started from it reported nothing and an archive
fetch would have been silent; and both toggles were bare Switches with no
accessible name, which TalkBack cannot announce and the UI checker cannot tap.
Schema v2 -> v3. The four non-null drip columns carry SQL defaults because
SQLite cannot add a NOT NULL column without one, declared on the entity too so
the migration and the exported schema describe the same table. Verified twice:
the migration test now covers 1 -> 3 straight through, and a real v2 database
seeded with a subscription, an episode at position 424242 and a rule was
upgraded in place on a device with all three surviving and drip defaulting off.
OPML moved to its own view model rather than growing ListenViewModel past its
function limit again -- it shares nothing with the library, is used by one
screen, and the settings screen now depends on it alone.
The exported-schema trap from the last commit happened again, after I had
written the warning down. Step 9 of the pre-commit hook now refuses a commit
that modifies an already-committed schema JSON, since a note was demonstrably
not enough.
No permission changes. 333 unit tests green, detekt and lint clean, coverage
63.63 -> 65.30.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
The first of Phase 2's three tracks. A rule is a set of conditions on a newly
discovered episode -- show, title contains/excludes, duration range, age, season
-- and an action: queue at the top or bottom, mark played, and optionally
download it or pin it to a speed. Rules are ordered, individually switchable,
and the first one that matches claims the episode.
Every claimed episode records which rule fired and the account that rule gave,
shown on the episode screen as `Handled by "News to the queue" - title contains
"news"`. That is not decoration. Automation that fills a queue and cannot say
why is the thing that makes people stop trusting it, so the reason string is
tested as hard as the matching is.
Three decisions worth keeping:
Rules never fire on a first subscribe. That import discovers the entire back
catalogue -- 2,753 episodes on a real feed -- and a catch-all rule would enqueue
all of it. `applyEpisodes` takes a `report` flag and `subscribe` passes false.
Verified live: subscribing to NPR News Now stored four episodes and queued none;
deleting the newest and refreshing queued exactly that one, with its reason
recorded.
A rule's speed lands on the episode, not the show. Writing it to
`SubscriptionEntity.playbackSpeed` would let one matching episode silently reset
the speed of everything else the show publishes.
Auto-downloads go through a new DownloadWorker rather than the refresh. A
refresh should finish in seconds whatever the rules decided; a worker that spent
twenty minutes pulling audio would be killed mid-flight with the feed state half
written. The drainer caps a batch at ten and leaves a failure FAILED rather than
retrying forever against a 404.
Schema v1 -> v2, this app's first migration, with MIGRATION_1_2 validated on a
device against the exported v1 schema and a seeded playback position read back
afterwards. Two pieces of plumbing were needed and both fail obscurely: the
schemas directory wired into androidTest assets, and kotlinx-serialization
raised to 1.8.1 because Room 2.8's bundle parser calls a method 1.7.3 lacks and
dies with AbstractMethodError at runtime. Both are written up in CLAUDE.md,
along with the trap that cost the most time -- changing an entity before bumping
the version rewrites the exported schema for the old version, destroying the
only record of what is on the phone.
No permission changes. 298 unit tests green, detekt and lint clean, coverage
61.60 -> 63.63.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
"App not installed" when updating. Android generates a debug keystore per
machine, so tungsten's build and the laptop's build are signed differently and
each refuses to install over the other. Reproduced exactly:
INSTALL_FAILED_UPDATE_INCOMPATIBLE, "signatures do not match newer version".
The published APKs show the collision plainly -- 0930d0 on the 21st, 106ecafe
in between, 0930d0 again after my last publish -- so an update had roughly a
coin-flip chance of being installable depending on which machine built it.
The checked-in key is tungsten's existing debug keystore, stock alias and
password, not a freshly generated one. That matters: a new key would have been
just as unusable as the mismatch, forcing a second uninstall and a second loss
of every playback position, right after Peter had already done one. Verified by
installing the currently-published APK and updating in place over it.
Safe to commit -- it signs debug builds of a personal sideloaded app. It must
never sign a release, where the key is the app's identity.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Found while auditing what phase 1 still owed: unsubscribe existed on the view
model and no screen called it, so there was no way to remove a podcast. Not
dead code -- a feature the user could not get to, which had survived the whole
phase.
The reachability guard covered helper objects and DAOs but not view models,
which is the third place this class has hidden. It now checks that every view
model function is reached by some screen, matching both a direct call and a
method reference. It fails on unsubscribe before the fix and passes after.
Unsubscribing asks first. It cascades to episodes, downloads and every
playback position for that show, and none of that is recoverable from
anywhere.
CLAUDE.md now records what phase 1 deliberately does not have -- artwork, a
player screen, queue reordering -- so the next session finds them written down
rather than rediscovering them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Two things from the car.
Skip-forward was missing: a head unit shows about three transport controls,
and leaving previous/next track available meant they took the slots. Previous
does nothing useful for a single episode anyway, so those commands are now
removed in onConnect and the seek buttons get the room.
Auto did not recognise Listen as driving-friendly, so it could only be
controlled from the phone. That needs a browsable library, which means a
MediaLibraryService rather than a MediaSessionService, plus a tree: Up Next
and Podcasts at the root, shows beneath, episodes beneath those, capped at 100
because one real feed here holds 2753.
Three things were each individually fatal and none failed loudly:
- The automotive_app_desc meta-data has to sit at application level. A
search-and-replace put a copy inside each activity as well, where it is
simply ignored.
- The service must be exported, because the controller is another process.
- onConnect must grant DEFAULT_SESSION_AND_LIBRARY_COMMANDS. With plain
DEFAULT_SESSION_COMMANDS a browser connects successfully and then gets
PERMISSION_DENIED on the library root -- the connection looks healthy and
browsing is dead. Found by logging the result code rather than assuming.
Voice search too, since in a car it is the only interaction that keeps your
eyes on the road. Shows rank before episodes: asking for a show by name is the
common case, and a show is browsable, so the driver still picks the episode.
MediaBrowseTest drives all of this over the real session protocol from a
MediaBrowser. It must run on the main thread and await rather than block --
MediaController rejects calls from another thread, and blocking the main
thread on a future that completes there deadlocks.
Also: XML comments cannot contain "--", which made the manifest unparseable
with a merge error that never named the line; and the reachability guard was
reporting a method reference (LibraryTree::subscription) as dead because it
only matched call syntax with parens.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Review found a data-losing bug. advance() drops the head of the queue, but
play() never put the played episode there. Play something from a show list,
let it finish, and advance dropped whatever was actually at the head -- a
different episode, removed from the queue having never been played.
The fix is an invariant rather than a special case: anything played becomes
the head, so advance always drops the episode that just finished. playNow()
does that, play() calls it, and the tests name the scenario.
Two DAO methods had no caller -- getQueue, superseded by observeQueue, and
observeUnplayed, written for a screen that was never built. Deleted. Room
generates an implementation for each, so they were not free.
The reachability guard now covers DAO methods too, which is the shape that
bit FeeDreader: three superseded methods sat undetected until a manual audit.
Interfaces need looser matching than objects, since a call's receiver varies
by site and a @Transaction default method calls its siblings unqualified, so
it matches both forms and errs toward "called" -- a false alarm costs more
attention than a missed one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Four coverage exceptions and five detekt relaxations, all deliberate and all
documented at the point they were taken. Writing them down in one place so a
future session can see the pattern rather than rediscovering each decision --
and so the question of whether framework adapters belong in the coverage
metric at all stays visible instead of being quietly settled by attrition.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Verified end to end on an emulator: the probe resolves a real video and the
download lands 54,006,240 bytes of m4a, matching the size the probe predicted.
Two traps found by running it rather than reasoning about it:
- Chaquopy defaults to CPython 3.8, which yt-dlp no longer supports, so pip
silently resolves 2024.10.22 -- the last release that did, nearly two years
stale, and YouTube extraction does not work with it. The build succeeds and
the feature is dead. Pinned to 3.12, which gets 2026.8.19, and
YouTubeAudioTest now fails on any pre-2025 yt-dlp so it cannot regress
quietly.
- Chaquopy 16.1.0 calls org.gradle.util.VersionNumber, removed in Gradle 9,
so Gradle is pinned to 8.14.3 here while FeeDreader stays on 9.5. AGP stays
at 8.13.2. Chaquopy 17 requires AGP 9.x, so moving off this means bumping
all three together.
yt-dlp's JSON is parsed through a separate, unit-tested mapping rather than
inline in the Chaquopy call. It is a separate project on its own release
cadence and is the boundary most likely to drift, so a field going missing or
changing type should fail a test rather than crash on the phone. Parsed with
kotlinx.serialization because org.json is an empty stub off device, which
would have made the mapping untestable.
Audio-only by design: separate streams need ffmpeg to merge, the documented
yt-dlp failure mode on Android, and a pre-muxed audio stream has nothing to
merge. No ffmpeg dependency at all.
No proof-of-origin token support, deliberately -- obtaining one means running
Google's attestation script in a WebView, a different act from downloading,
and it is not needed from an ordinary connection.
Known risk recorded in CLAUDE.md: yt-dlp has deprecated YouTube extraction
without a JavaScript runtime, and there is no deno or node on Android.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Sibling to FeeDreader, not a shared module. That was a considered call: its
FeedParser only inspects <enclosure> while hunting for a thumbnail, and its
FilterRuleEngine is 46 lines of title substring matching that doesn't
generalise to queue rules, so only Opml.kt would genuinely have transferred.
The parser is the piece everything else sits on, so it goes first, test-first:
18 tests covering audio/video enclosure selection, iTunes durations in both
second and clock form, season/episode numbers, podcast:chapters for the skip
feature, RFC 5005 archive paging for drip subscriptions, and episode identity
falling back to the enclosure URL when a guid is missing.
Two things worth recording:
- Built on XmlPullParserFactory rather than android.util.Xml. The latter
returns null off-device, which is why FeeDreader's 297-line parser has no
tests at all. Both resolve to the same KXmlParser on Android.
- SimpleDateFormat with isLenient=false rejects "Tue, 02 Sep 2026 06:00:00
GMT" -- an entirely ordinary RFC 822 date. Left strict, every pubDate in
the app would have been null and episode ordering would have been silently
wrong everywhere.
minSdk is 29, deliberately not FeeDreader's 36, which would restrict the app
to Android 16 and newer.
Permissions: INTERNET and ACCESS_NETWORK_STATE for feeds; FOREGROUND_SERVICE,
FOREGROUND_SERVICE_MEDIA_PLAYBACK, POST_NOTIFICATIONS and WAKE_LOCK because
Android requires a foreground service with a notification to play audio in the
background. That notification is the transport control, not an alert -- Listen
posts no new-episode notifications by design.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|