summaryrefslogtreecommitdiff
AgeCommit message (Collapse)Author
2026-09-25Simple skip: manual offsets and chapter-marked breaksPeter Stone
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
2026-09-25Slow drip: work through an archive a few episodes at a timePeter Stone
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
2026-09-25Auto-queue rules: decide what happens to an episode as it arrivesPeter Stone
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
2026-09-25Add single videos, make the whole episode row tappable, stamp the buildClaude
Sharing a YouTube video now adds that one video rather than subscribing to its channel -- sharing something interesting is not a request to follow everything the channel ever posts. They land in one "Saved videos" container instead of a subscription each, which would otherwise fill the library with entries that will never publish again. Title, artwork and duration come off the watch page we already fetch, so adding needs no extractor. Only the episode title was tappable, which is too fine a target: Peter tapped the row, nothing happened, and reasonably concluded the details screen wasn't there. The whole row opens it now. Settings shows the commit and build time. A cached browser download and a failed publish look identical from the outside, and "is this the new build?" had no answer that did not amount to trusting me. publish-apk writes a companion .txt with the hash for the same reason. Metadata is entity-decoded. A YouTube thumbnail URL carries query parameters and the raw attribute encodes the separators as &amp;, so the artwork simply failed to load -- spotted in the real resolved output rather than by a test. YouTube URL handling moved to its own YouTubeUrls object. Channel, playlist, handle and watch URLs each need different treatment and two only resolve by reading the page; that is its own concern, not a corner of the general resolver. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-25Mark-older-as-played, episode details, unplayed-first sortingClaude
After an OPML import every episode of every show arrives unplayed -- a back catalogue already heard, far too large to tap through. markPlayedUpTo marks an episode and everything older in one action. It skips episodes already played, so their original dates survive, and clears positions as it goes: retention reads a non-zero position as "resumed, still in use" and would otherwise never reclaim those files. Sorting now ranks by newest *unplayed* episode rather than newest episode. A show you are caught up on was sitting at the top with nothing to offer, which is backwards; it now drops down the list until it publishes again. An episode details screen, because the list can show two lines and the description -- often the only place a show says what an episode is about -- had nowhere to go. It is also where the bulk action lives: the row already has five controls and a sixth did not fit. One bug worth recording. SubscriptionNav.openEpisode had a `= {}` default, so the subscription route forgot to pass it and tapping an episode title silently did nothing -- it compiled, it ran, the button was simply dead. Default no-ops on navigation callbacks turn a missing wire into a dead control instead of a compile error, so the default is gone. Verified on device: details screen opens, and against real Room on a live feed the bulk action marked 498 older episodes while leaving the 2 newer alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-25Sign debug builds with a shared, checked-in keystoreClaude
"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
2026-09-24Fix YouTube subscriptions, headphone skip, and NPR show namesClaude
YouTube was broken in two separate ways and only the first was visible. Resolution rejected anything without a channel id in the URL -- which is every link YouTube's share sheet produces. The id now comes from the page, so handles, /c/, /user/ and watch URLs all work. Then the deeper one, from Peter testing the link that did resolve: it "subscribed but yields an empty feed". YouTube's feeds are Atom and carry no <enclosure> at all, just <yt:videoId> and a watch link, so the parser dropped every single entry for having no playable media. Subscriptions were being created permanently empty. The watch URL is now the media URL, which is exactly what the yt-dlp path already expects; a podcast item with no enclosure is still dropped. Requests now send a browser user agent. I had refused this on the grounds it was defeating an access control, which was wrong and Peter said so: fetching a public page is content negotiation, not authentication. It is materially different from a proof-of-origin token or a borrowed login, neither of which Listen does. YouTube serves a 2KB stub to anything that doesn't look like a browser, so this is also what makes video links resolve at all -- verified on device, all three of his URLs now subscribe with 15, 15 and 13 episodes. Headphone skip-forward: I broke this removing next/previous commands to free car-notification slots -- headphone remotes send exactly those. Restored, and PodcastPlayer redefines them as skip-forward/back, so they do the useful thing on a podcast instead of nothing. Every NPR show displayed as "NPR" because artist preferred itunes:author, and NPR sets that identically on every feed. Show title comes first now. Also: release dates on the episode list, podcasts sorted by most recent episode rather than alphabetically, a per-feed refresh button, speed chips that wrap instead of overflowing, and the now-playing bar taller, carrying the show name, and present on the queue and podcast screens rather than only the library. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-23Add artwork, a real player screen, and queue drag-reorderPeter Stone
Closes three of the four Phase 1 gaps in CLAUDE.md's "Known gaps": artwork was parsed and passed to the lock screen but never rendered, playback had a transport bar with no scrubbing or per-show speed, and QueueOrdering.move was tested but unreachable from any screen. Android Auto real-car verification stays deferred, per Peter's call. - Artwork: new Artwork.preferredUrl (episode image falling back to the show's), resolved once at EpisodeEntity.toMediaItem's MediaMetadata boundary so the lock screen, Android Auto, the now-playing bar and the new player screen all read it from one place. Coil3 wired to the app's own OkHttpClient. Pinned to 3.3.0: later releases require a newer Kotlin stdlib or Compose than this project runs (Compose 1.12 needs compileSdk 37, one above what AGP 8.13.2 supports). - Player screen: scrubbing (PlayerConnection.seekTo, committed on drag release) and per-show speed (PlayerViewModel.setShowSpeed persists SubscriptionEntity.playbackSpeed via a new SubscriptionDao.updateSpeed) -- the field existed since Phase 1 with nothing to reach it. Opened by tapping the now-playing bar's title/artwork; Up Next moved to its own icon there. - Queue reorder: drag-and-drop via sh.calvin.reorderable, backed by a new QueueRepository.reorder that writes the final order once on drag release rather than per drag step (the gesture reports intermediate positions continuously). Coverage baseline drop (62.10% -> 61.15%) is entirely in already-0%-covered Media3 glue -- see COVERAGE_EXCEPTION. The new pure logic (Artwork, QueueRepository.reorder) is fully covered by new tests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sj3CoWEedM56bebByKgNxA
2026-09-24Ignore Kotlin's build session directoryClaude
.kotlin/ is a build artifact and was untracked but not ignored, so it would have been committed the first time it held anything. Noticed by diffing a fresh clone of the new local remote against the working tree -- which is the sort of thing only a clone tells you. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-21Make unsubscribe reachable, and guard view models for reachabilityClaude
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
2026-09-21Android Auto support, and give the car its skip-forward buttonClaude
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
2026-09-21Notification seek buttons, and resolve redirects to the real feed URLClaude
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
2026-09-21Fix a queue bug that deleted episodes, and delete dead DAO methodsClaude
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
2026-09-21Icon actions, dark-only theme, real media metadataClaude
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
2026-09-21Pin the reported NPR page shape with a regression testClaude
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
2026-09-21Actually resolve shared links, and guard against dead codeClaude
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
2026-09-21Record phase 1 state and the gate overrides in CLAUDE.mdClaude
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
2026-09-21Playback controls, background workers, settingsClaude
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
2026-09-21UI, wiring, and an end-to-end test against a real feedClaude
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
2026-09-21Episode downloads, with the YouTube path kept separateClaude
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
2026-09-21Playback: position persistence, completion rules, sleep timerClaude
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
2026-09-21Subscription resolution, OPML, queue ordering, retentionClaude
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
2026-09-21Data layer: schema, episode matching, conditional-GET refreshClaude
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
2026-09-21Merge in-app yt-dlp: verified working end to end, Termux no longer neededPeter Stone
2026-09-21Embed yt-dlp via Chaquopy instead of shelling out to TermuxClaude
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
2026-09-21Scaffold Listen, with a podcast-native feed parserClaude
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