| Age | Commit message (Collapse) | Author |
|
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
|
|
Two things reported from the phone.
The playing notification had a previous button and no skip-forward. Media3's
default layout offers previous/next *track*, right for an album and wrong for
a podcast: with one episode loaded there is no next track, so a control was
dead and the useful ones absent. Skip-by-seconds are not default buttons --
they are custom session commands, and they have to be granted on connect or
the buttons appear and do nothing. Media3 ships numbered icons for 5, 10, 15
and 30 only, so back gets the 15 icon and forward gets a plain arrow rather
than one captioned 30; an icon stating the wrong number is worse than one
stating none.
The resolver returned the URL it was handed, not the one redirects landed on.
A share link from another app is a redirect to the real feed, so subscribing
through one stored that third party's URL as the feed: retire the shortener
and the subscription silently stops updating even though the publisher never
moved. It now resolves to the final URL, and discovered relative links resolve
against where the page actually was rather than where we asked.
Verified on device: an Apple Podcasts link for Common Sense resolves to
feeds.feedburner.com/dancarlin/commonsense and subscribes under the
publisher's own URL.
Also fixes lint errors this introduced -- Media3's session API is @UnstableApi
and the error result code belongs to SessionError, not SessionResult.
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
|
|
Five labelled buttons overflowed the episode row on a phone and the last one
collapsed into a one-letter-per-line column. They are icons now, each keeping
a contentDescription so the row reads the same to a screen reader as it did
with labels. Verified on the emulator against the real NPR feed: seven
episodes visible where four fitted before.
Dark only, not a dark default with a light alternative. This is a listening
app used in the dark -- in bed with a sleep timer running, or in a car -- so
the light theme is the one nobody would pick and it isn't built. The
unqualified resources carry the dark values too, otherwise the window flashes
white before Compose draws its first frame.
Media metadata was title-only, so the lock screen and any car head unit showed
blank lines where artist and album belong. It now carries the show as artist
and album, plus the artwork URI.
Seek increments are set on the player itself, which is what the notification,
lock screen, Bluetooth remote and car controls actually use -- they were
running on Media3's defaults, not the app's. Back 15, forward 25, and the
in-app bar now uses the same numbers so the two can never disagree about what
a skip does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Verified end to end on device against the actual URL from the bug report:
npr.org/podcasts/510318/up-first resolves to feeds.npr.org/510318/podcast.xml
and subscribes as 'Up First from NPR' with 500 episodes.
The page carries two rel="alternate" links: the RSS feed, and an
android-app:// deep link with no type attribute at all. Requiring a recognised
feed type is what keeps the deep link from being chosen -- it would resolve to
something unfetchable. That was already the behaviour; it now has a test
naming the case, since "we happen to filter on type" and "we must filter on
type" look identical until something changes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Bug report from the phone: pasting a link produced "Could not parse feed:
Unexpected <! (position:START_DOCUMENT...)" -- the XML parser meeting
<!DOCTYPE html>. The cause was mine. SubscriptionResolver.findFeedLink and
applePodcastId were written, unit-tested, documented as how the share sheet
would work, and had zero call sites. The raw URL went straight to the parser.
FeedResolver now does what I said the app already did: a direct feed resolves
to itself, a show's web page resolves through its link rel=alternate, an Apple
Podcasts link goes through the iTunes lookup, and a YouTube channel or
playlist uses YouTube's own RSS endpoint. Both entry points -- the in-app add
box and the share sheet -- go through it, so they cannot drift apart again.
Verified on-device against real sites: thisamericanlife.org resolved to
/podcast/rss.xml, a direct megaphone feed passed through unchanged, and a
YouTube handle failed with an explanation rather than opaquely.
Errors are now written for a person. "Unexpected <!" told the user nothing;
"That's a web page, not a podcast feed, and it doesn't link to one" tells them
what to do. A test asserts parser internals never reach the UI.
Fixing the class, not just the instance:
- A reachability test fails when a helper object's function has no production
caller. Its first version passed on the very code it was written to catch,
because a bare name also matches a KDoc cross-reference -- it now requires a
call-shaped match. Verified by reintroducing the original bug and watching
it fail.
- That audit found a real functional gap, not just unused code:
QueueRepository.advance had no caller, so an episode ending never advanced
the queue. Playback now marks the episode played, advances, and starts the
next 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
|
|
The app can now actually play something. A MediaController binds the UI to the
playback service rather than reaching for the ExoPlayer instance directly --
the service can restart independently of the activity, and a stale direct
reference would fail with no obvious symptom. Resume goes through
PlaybackProgress, so an episode whose stored position is past a re-encoded end
restarts rather than seeking past the end and stalling.
Skip is asymmetric: 15 seconds back for a missed sentence, 30 forward for an
ad break.
Workers: refresh every six hours on unmetered network only -- refreshing feeds
is cheap but the downloads that follow are not, and a phone on mobile data
should not discover that by itself -- and retention every twelve. Both enqueue
with KEEP rather than UPDATE, since UPDATE resets the interval on every launch
and on a frequently-opened app means periodic work effectively never fires.
Retention's actual logic is in RetentionSweeper rather than the Worker,
because it deletes a listener's audio and the failure mode is silent and
permanent. It only clears the database row once the file is really gone: the
other order orphans the file, since nothing else knows about it and the disk
would never be reclaimed. A row pointing at an already-missing file is still
cleared, or it stays a permanently unplayable DOWNLOADED entry.
Settings reaches the three features that had tested logic and no way to run
them: OPML import and export through the system file picker, playback speed,
and the sleep timer. Import reports how many feeds failed, not just how many
succeeded -- silently importing 40 of 60 only surfaces weeks later when a show
never appears.
ListenViewModel was split rather than raising detekt's function-count limit a
second time. Playback controls operate on whatever is playing regardless of
screen, which is a real seam rather than a count.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Library, subscription and queue screens over the finished data layer, plus a
share receiver that actually resolves rather than toasting a placeholder.
Two things the screens deliberately surface: a subscription whose last refresh
failed shows the reason inline, because a feed that has quietly stopped
working is otherwise indistinguishable from one with nothing new; and a failed
subscribe reports the actual error rather than "could not subscribe", since a
share sheet gets exactly one chance to tell the user anything.
The share receiver had a real bug on the way in -- finishing the activity to
dismiss the "Resolving..." toast cancelled lifecycleScope and took the network
call with it. It now stays alive until the subscribe completes.
Screens.kt was split into one file per screen rather than relaxing detekt's
function-count rule a fourth time. Four exemptions in one session starts
sanding the gate down instead of using it.
QueueRepository is tested for the invariant that matters: every operation
leaves queue positions contiguous from zero, since a gap or duplicate renders
wrongly and reorders unpredictably.
The end-to-end instrumented test is the part the JVM suite cannot do: it runs
against real Room rather than fake DAOs, so the schema, converters and
constraints are actually executed. Against a live feed it stored 2753 episodes
and a second refresh returned Unchanged, exercising conditional GET and the
matcher's identity rules on real data instead of fixtures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Two sources with no fallback between them. A YouTube subscription's media URL
is a watch page, not audio: fetching it over plain HTTP would store an HTML
document under an .mp3 name that fails to play with nothing to explain why.
The subscription kind decides the route.
Audio is written to a .part name and renamed only once complete. A truncated
file recorded as DOWNLOADED plays as a cut-off episode and reads like the
publisher's fault. On failure the partial is removed and localPath cleared,
so a FAILED row never points at a file something downstream might try to play.
A download recorded as complete whose file has since been deleted is fetched
again rather than trusted, because otherwise playback fails on a file the
database insists exists.
File names are derived from the episode id with separators stripped. Ids come
from feeds, and a guid containing a path separator would otherwise write
outside the library directory.
The Chaquopy-backed source sits behind an interface so the downloader's own
logic stays testable off-device; nothing in the real source can run without an
Android Python runtime.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Where a listener got to is the only data here that cannot be reconstructed
from anywhere, so the rules around it are pure and tested rather than tangled
into Media3 callbacks.
PositionWriter depends on a narrow Playhead interface rather than Media3's
Player, which is far too large to fake. That was driven by the coverage gate
rejecting the first version: the component holding the app's only
irreplaceable data was reachable only from an emulator. Testing it caught a
real bug -- switching episodes carried the previous episode's write threshold
across, so a new episode's early position looked like a tiny move and went
unrecorded.
Completion is not a percentage. A test caught that too: 98% of a four-hour
recording still has five minutes of content, so the threshold now only applies
when what remains is plausibly credits rather than programme. A stored
position beyond the end restarts instead of seeking past the end and stalling,
which is what happens after a publisher re-encodes an episode.
Position is written on an interval, immediately on any backward seek, and
flushed on task-removal and destroy. Android kills a backgrounded media
process without warning, so waiting for a clean stop loses it.
The sleep timer fades rather than cuts. Extending adds to the time remaining
rather than resetting: "+5 minutes" with 30 seconds left means five and a
half, and silently dropping those 30 seconds is bad in a control used
half-asleep. My first implementation had this backwards; the test caught it.
Audio focus ducks rather than pauses -- spoken word survives ducking for a
navigation prompt far better than a pause mid-sentence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Discovery was cut, so the share sheet and OPML are the only ways to subscribe.
That raises the bar on the resolver: anything that fails to resolve is a show
the user cannot add at all. It handles a direct feed, a show page via its
link rel=alternate, an Apple Podcasts link via its numeric id, and a YouTube
channel or playlist via the RSS endpoint YouTube already publishes -- which
means a channel becomes an ordinary subscription and only the download step is
special. Handle (@name) URLs carry no channel id and return null rather than
being guessed at.
OPML is ported from FeeDreader but onto XmlPullParserFactory, so unlike the
original it is actually testable off-device. Folder nesting is read and
recorded even though Listen has no folders: silently discarding structure the
user organised is worse than ignoring it.
Queue ordering is pure list operations. Re-adding a queued episode moves it
rather than duplicating it, play-next on the episode already playing is a
no-op instead of interrupting it, and an out-of-range drag index is ignored
rather than thrown.
Retention keeps an episode when its position is non-zero even though it is
marked played -- that combination means it was resumed, and the audio is in
use. Disabled is a flag, not an enormous grace period, because
playedAt + Long.MAX_VALUE overflows negative and would delete everything.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Episode identity is the part worth care. Publishers re-host and regenerate
guids, and the naive result is a duplicate episode and a listener who has lost
their place in a three-hour recording. EpisodeMatcher falls back guid ->
enclosure URL -> title+date, claims each stored episode at most once, and
carries position, played state and download state across untouched. Title
alone never matches, since a weekly show reuses one.
Refresh sends stored ETag/Last-Modified so the common case -- nothing new --
is a bodyless 304 rather than re-downloading every feed on the phone. A 304 is
a distinct outcome, not an empty feed, and absence from a feed page is never
treated as deletion: feeds are routinely truncated.
Failures are recorded on the subscription, not logged. A feed that has quietly
stopped working is otherwise indistinguishable from one with no new episodes,
which is the failure mode that has bitten every project in this workspace.
Note on process: SubscriptionRepository was written before its tests, against
the rule in CLAUDE.md, and all 11 passed first run -- exactly the symptom that
rule exists to catch. Rather than assume they were meaningful I mutation-tested
them: removing the failure recording is caught by two tests, and misreporting a
304 as an update is caught by one. A third mutation went uncaught but turned
out to be inert, since the matcher has no delete path at all.
No destructive migration fallback. Playback positions and play history are not
reproducible from anywhere, so a missing migration must fail loudly rather than
silently wipe the library.
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
|