# Listen — Agent Instructions Personal Android podcast app, `org.terst.listen`. Sibling to FeeDreader (`/workspace/feedreader`), built for Peter's own use. Greenfield, hands-on-testing-rounds workflow: sideload a debug build, use it, batch feedback. Design plan: https://claude.ai/code/artifact/85137e78-3718-4d84-8ab8-f72885170653 ## What this app is, and isn't Replacing Pocket Casts completely. The distinguishing features are audio-based intro/outro skipping, slow-drip archive subscriptions, and rule-driven auto-queueing. **Non-goals, deliberately:** anything social, new-episode notifications (or any alert — the media notification is a transport control and is mandatory for background audio, which is a different thing), favourites, bookmarks. Discovery and video were both cut from scope. ## Relationship to FeeDreader Separate repos, no shared module. That was a considered call, not an accident: FeeDreader's `FeedParser` treats `` as an image source and its `FilterRuleEngine` is 46 lines of substring matching that doesn't generalise to queue rules, so only `Opml.kt` (~96 lines) genuinely transferred. **Standing instruction:** watch for things that do generalise, and avoid pointless drift between the two apps. If a change here would obviously improve FeeDreader too, say so. Don't force sharing where the problems are actually different — but don't let two solutions to the same problem quietly diverge either. ## Test-driven development is mandatory Same rule as FeeDreader, same reasons. Every change to `app/src/main/` follows red-green-refactor, in that literal order: 1. **Write the test first**, for the specific behaviour you're about to add or change. 2. **Run it. Confirm it fails, and fails for the right reason** — an assertion mismatch showing the missing behaviour, not a compile error. 3. Write the minimal production code to pass. 4. Run again. Confirm green. 5. Refactor if needed, keeping it green. 6. Run the full quality gate before committing. **If production code was written before or without a preceding failing test**, discard the diff and redo it test-first. Do not backfill tests onto existing code — a test written against working code confirms what the code does, not what it should do. **Scoping exception:** pure resource/config/manifest changes with no unit-testable behaviour don't get a red step; the static-analysis gate covers those. ## Verification — mandatory **Never conclude a build or test result from grepped console output**, and never verify a launch with `adb shell monkey`. Use the shared wrappers, which report from exit codes, parsed JUnit XML, and a logcat capture started before launch: ``` /workspace/devtools/bin/gradle-test /workspace/listen -- --rerun-tasks /workspace/devtools/bin/gradle-report /workspace/listen detekt /workspace/devtools/bin/android-launch-check org.terst.listen \ --apk app/build/outputs/apk/debug/app-debug.apk --settle 15 /workspace/devtools/bin/android-ui org.terst.listen dump /workspace/devtools/bin/publish-apk \ app/build/outputs/apk/debug/app-debug.apk listen-debug.apk ``` Migrations only prove themselves on a device. Every version bump runs: ``` /workspace/devtools/bin/gradle-verify /workspace/listen connectedDebugAndroidTest -- \ -Pandroid.testInstrumentationRunnerArguments.class=org.terst.listen.data.MigrationTest ``` **Never read `app/build/reports/detekt/detekt.xml` directly.** An invalid config aborts detekt before it analyses anything and leaves the previous run's report in place; it was read as current findings three times. `gradle-report` refuses a report older than the run. **Never `adb shell input tap X Y`.** A blind tap landed in FeeDreader's settings and the screenshot after it was reported as Listen. `android-ui` checks the foreground package first and taps by label. See `/workspace/devtools/README.md`. These exist because grepped console text has produced wrong claims three separate times across these repos. ## Quality gate — every commit 1. **Build** — `./gradlew compileDebugKotlin` succeeds. 2. **Tests** — `./gradlew testDebugUnitTest` passes in full. 3. **Static analysis** — `./gradlew detekt lintDebug` clean: zero lint errors, zero new detekt findings beyond the baseline. 4. **Coverage must not decrease** against `.coverage-baseline` (line coverage over `app/src/main/java` excluding `ui/**`). Set `COVERAGE_EXCEPTION="reason"` for justified drops, e.g. deleting covered dead code. 5. **Manifest permission diff** — if `` changed, the commit message says why. 6. **Schema migrations** — if `AppDatabase.kt`'s `version` changed, a migration test must be touched in the same diff. Room tracks by version; a wrong migration corrupts a real, non-reproducible library. 7. **Exported schemas are append-only** — a commit that *modifies* an existing `app/schemas/**/*.json` is refused. Adding a new one is how a version bump is supposed to look; changing an old one means the record of what is on the phone has been overwritten. ## State **Phase 1 is complete.** Subscribe (share sheet, pasted URL, OPML import), refresh, download, queue, play, variable speed, sleep timer, retention, unsubscribe, Android Auto with a browsable library and voice search. 207 unit tests plus instrumented tests covering the media browse tree. **Since Phase 2**, in response to a round of Peter's feedback: yt-dlp sources play (they are fetched first), playback errors are surfaced instead of being swallowed, anything queued is downloaded at once, Facebook videos are supported, the library ranks shows by their newest *unqueued* episode, and trailer/preview filter rules ship as defaults. **Phase 2 is complete**, in three tracks. Peter deferred the audio-fingerprint skip detection (the design plan's Tier 3), so the simple skip work came last. - **Track 1, auto-queue rules — done.** Named, ordered, enable-able rules with conditions on show, title, duration, age and season, and actions to queue top/bottom, mark played, auto-download, or pin a speed. `RuleEngine` decides, `RuleApplier` carries it out, `RuleSummary` describes a rule in one line, and every claimed episode records which rule fired and why. - **Track 2, slow-drip archives — done.** Per-show cadence (`DripSchedule` decides what is owed, `DripReleaser` pays it out), a released-set the show and Android Auto both list, and `FeedArchiveWalker` following RFC 5005 `prev-archive` pages to fetch a back catalogue. A drip release always enqueues and deliberately bypasses the rule engine. - **Track 3, simple skip — done.** Manual per-show intro/outro offsets through `PlaybackProgress`, and Podcasting 2.0 chapters: `ChapterParser`, a chapter list in the player, and opt-in skipping of breaks the publisher marked `toc: false`. Nothing is guessed from chapter titles. **Tier 3 skip detection — audio fingerprinting of theme music — is still not built.** Peter deferred it; the design plan puts it in Phase 3 and wants a desktop prototype against real episodes before any Android code. ID3 `CHAP` frames are also not read: that means parsing ID3 out of a possibly-partial MP3, and the JSON path covers the shows that mark chapters at all. **Known gaps in what is built** (deliberate, not forgotten): - **Ad-chapter skipping trusts `toc: false` and nothing else.** No title matching: a show discussing its own sponsorship deal has a chapter called "Sponsors" that is the actual programme. It is also off by default per show, being the one part of the feature that can remove audio nobody has heard. - **Auto-downloads are not retried.** A rule's download that fails is left FAILED rather than requeued: retrying forever against a 404 would burn the battery, and the failed row is visible in the episode list. Retry is the manual Download button. - **Android Auto is unverified in a real car.** The session protocol is proven by `MediaBrowseTest`; whether Auto's own compatibility checks pass on a head unit is untested, and a sideloaded media app may need Auto's developer mode enabled. ## Domain notes worth keeping - **Playback position is the crown jewel of the data model.** It must survive process death. Save on a timer, on pause, and on lifecycle transitions — not just on stop. - **Never assume audio.** An enclosure carries a MIME type. Video was cut from scope, but the model stays media-agnostic so it isn't a retrofit. - **GUIDs move.** Publishers rewrite feeds; match episodes on GUID first, then enclosure URL, then title+date. An episode that loses its identity loses its playback position. - **Retention is auto-delete-once-played** with a grace period, so a mis-tap doesn't destroy an episode you were still listening to. - **A yt-dlp source has no playable URL until it is fetched.** A YouTube or Facebook episode's `mediaUrl` is its watch page; the audio URL only exists once yt-dlp has resolved it. `SubscriptionKind.needsExtraction` says so, `PlaybackAdvancer.start` reports `NeedsFetch` rather than a `NextUp` that cannot work, and `fetchAndStart` does the fetch. Both the service and the view model go through it -- the queue advances without the UI. - **Never `bestvideo+bestaudio` in `ytaudio.py`.** That is the form that needs ffmpeg, which is not on Android. Every selector in `_FORMAT` picks one already-complete file. Audio-only comes first, then a muxed mp4: **Facebook publishes no audio-only stream at all**, only pre-muxed sd/hd, so the original audio-only-or-nothing selector failed with "Requested format is not available". A muxed mp4 is still a single file and Media3 plays its audio track. - **YouTube audio is yt-dlp, embedded.** Chaquopy runs an in-process CPython with real yt-dlp; see `youtube/YouTubeAudio.kt` and `src/main/python/ytaudio.py`. No Termux, no podkit, no server round trip. - **Pin the Python version.** Chaquopy defaults to 3.8, which yt-dlp no longer supports, so pip silently resolves the last 3.8-compatible release (2024.10.22) and YouTube extraction fails while the build succeeds. `YouTubeAudioTest` fails on a pre-2025 yt-dlp for exactly this reason. - **Audio-only, always.** Separate audio and video streams need ffmpeg to merge, which is the documented failure mode for yt-dlp on Android. A single pre-muxed audio stream has nothing to merge and needs no ffmpeg. - **No proof-of-origin tokens.** Obtaining one means running Google's attestation script in a WebView, which this app does not do. It works without one from an ordinary phone connection. - **Known risk:** yt-dlp has deprecated YouTube extraction without a JavaScript runtime and warns that some formats may be missing. There is no deno/node on Android. If yt-dlp makes a JS runtime mandatory, this breaks and the fallback is bundling a JS engine it accepts. - **Gradle is pinned to 8.14.3, not 9.x**, because Chaquopy 16.1.0 calls `org.gradle.util. VersionNumber`, removed in Gradle 9. Chaquopy 17 moved to AGP 9.x; going there means bumping AGP, Gradle and Chaquopy together. This is the one deliberate divergence from FeeDreader. ## Things a future session should know - **The coverage gate has been overridden four times**, each recorded in the commit message. All four are Android-framework glue that cannot execute off-device: the Chaquopy bridge, PlaybackService, ChaquopyYouTubeSource, PlayerConnection and the two Workers. Each time the gate fired it was a useful prompt — it drove PositionWriter onto a narrow Playhead interface and pulled RetentionSweeper out of its Worker, both of which found or prevented real bugs. If the exceptions keep accumulating, the honest fix is a jacoco exclusion for framework adapters, which is a decision for Peter rather than something to slip in. - **Detekt config has been relaxed five times**, all documented inline in `config/detekt/detekt.yml`. Twice the better answer was restructuring instead: `Screens.kt` was split per screen and `ListenViewModel` was split into library and player halves rather than raising a limit again. - **Debug builds are signed with the checked-in `keystore/listen-debug.keystore`**, not the per-machine one Android generates. Two machines build this app, and differently-signed APKs refuse to install over each other: "App not installed", or `INSTALL_FAILED_UPDATE_INCOMPATIBLE`, with the only remedy an uninstall that destroys every playback position. The checked-in file is tungsten's original debug keystore, with the stock alias and password, because that is the key already on the phone. Never swap it for a fresh one — that costs another uninstall. Never use it for a release. - **Facebook is individual videos only.** Facebook pages publish no RSS, so there is no equivalent of subscribing to a YouTube channel. Shared videos land in their own "Saved Facebook videos" container, because the container carries the kind that decides how its episodes are fetched and one row cannot be honest about two sources. - **Facebook tells a non-browser nothing.** It serves no `og:` tags at all -- verified with the app's own user agent -- so a shared video gets a fallback title of "Video ". yt-dlp knows the real title (`probe` in `ytaudio.py` returns it, and `YouTubeAudio.probe` is still sitting in the reachability allowlist waiting for a caller). Backfilling from it is the obvious fix and is not done yet. - **Anything entering the queue is downloaded immediately, on any network.** The hook is `QueueRepository`'s `onQueued`, fired with newly-present ids only, because the queue is the one choke point every route goes through. The one-off job deliberately has no network constraint; only the periodic sweep is wifi-only. - **Feeds are big.** A real subscription in testing produced 2753 episodes in one refresh. Any per-episode work — a query in a loop, an O(n²) match — needs to be considered at that scale. - **The service is a `MediaLibraryService`, not a `MediaSessionService`.** Android Auto only treats an app as driving-friendly if it can browse a media library. Three things are each individually fatal and none of them fail loudly: the `automotive_app_desc.xml` meta-data must sit at *application* level, the service must be `exported` (the controller is another process), and `onConnect` must grant `DEFAULT_SESSION_AND_LIBRARY_COMMANDS` — with plain `DEFAULT_SESSION_COMMANDS` a car connects successfully and then gets PERMISSION_DENIED on the library root. `MediaBrowseTest` walks the tree over the real protocol to catch all three. - **Car head units show about three transport controls.** Leave `COMMAND_SEEK_TO_PREVIOUS`/`NEXT` available and they take the slots, pushing skip-forward off the screen. They are removed in `onConnect` so the seek buttons get the room. - **Bump `version` before you change an entity, not after.** Room rewrites the exported schema for whatever version is declared, so editing `EpisodeEntity` while the database still says `version = 1` silently overwrites `schemas/1.json` with the new shape. That file is the only record of what is on the phone, and once it matches the new code the migration test validates happily against a schema that never existed. This happened twice in one session -- once building the rules table, once building drip -- the second time *after* writing this warning down. Step 9 of the pre-commit hook now refuses a commit that modifies an already-committed schema JSON, because a note was demonstrably not enough. If it fires: bump the version first, then `git checkout -- app/schemas/`. - **The migration test needs two pieces of plumbing that fail obscurely.** `MigrationTestHelper` reads the exported schemas from the instrumented test APK's assets, so `schemas/` is wired into `androidTest`'s asset dirs in `build.gradle.kts`. And Room 2.8's schema-bundle parser calls a kotlinx-serialization method 1.7.3 does not have -- the version `lifecycle-viewmodel-savedstate` drags in -- which surfaces as `AbstractMethodError` on `GeneratedSerializer` at runtime, not as a build failure. The app's own serialization dependency is pinned at 1.8.1 to raise the floor; AGP resolves the androidTest classpath consistently with the app's, so pinning it only in `androidTestImplementation` does nothing. - **Rules must never fire on a first subscribe.** `SubscriptionRepository.applyEpisodes` takes a `report` flag for exactly this: the first import discovers the entire back catalogue -- 2,753 episodes on a real feed -- and a catch-all rule would enqueue all of it. Verified on a live NPR feed: subscribing stored four episodes and queued none. - **`android-ui dump` does not list every rendered Text.** The rule attribution line on the episode screen is absent from the dump and plainly visible in a screenshot. Treat a missing line in a dump as inconclusive, not as absence. - **A bare `Switch` has no name.** It announces nothing to TalkBack and `android-ui tap` cannot find it, so both toggles carry an explicit `contentDescription` -- and the drip one says "Turn slow drip on or off" rather than "Slow drip", because a description identical to the heading beside it makes the tap ambiguous. - **`connectedDebugAndroidTest` uninstalls the app when it finishes**, taking the database with it. Isolated on the emulator: a plain `adb install -r` left four episodes in place, and the very next instrumented run left `run-as: unknown package`. So an empty or missing database after an instrumented run is the harness, not a migration -- and any device state you want to keep has to be re-seeded afterwards. Migrations were separately proven safe by seeding a real v2 database (a subscription, an episode at position 424242, a rule) and upgrading it in place by launching the v3 build: all three survived, with drip defaulting to off. - **The drip released-set is a query parameter, not stored state.** `observeVisible`/`getVisible` take `dripEnabled` and hide unreleased episodes only when it is true, so switching drip off restores the whole catalogue without touching a row, and switching it back on resumes where it left off. `getForSubscription` deliberately still returns everything: feed reconciliation must see held-back episodes or it re-inserts them on every refresh. - **Completion belongs to the service, not to a view model.** Marking an episode played, advancing the queue, the outro offset and ad-chapter skipping all live in `PlaybackService` via `PlaybackAdvancer` and `PlayheadPolicy`. They used to live in `PlayerViewModel`, which is scoped to the activity and releases its MediaController in `onCleared` -- so with the activity gone the audio played on from the foreground service while nothing marked anything played or moved the queue on. Background listening is the normal case for this app. Verified with "don't keep activities" on: with no activity alive, an episode reached its outro line, was marked played, and the next one started. `PlayerConnection` deliberately no longer handles `STATE_ENDED`, and `PlayerViewModel` no longer has an `onFinished`. Putting either back means the queue advances twice. - **Where an episode starts is decided in exactly one place**, `PlaybackAdvancer.start`, used by both the service (queue advance) and the view model (a tap on Play). Two copies of the resume rules would drift. - **A start position belongs to `setMediaItem`, not a `seekTo` after `prepare()`.** Seeking afterwards is silently dropped across the MediaController-to-service boundary. This was found wiring up the intro offset -- an episode set to start sixty seconds in began at zero -- and it had been quietly breaking *every resume* since phase 1: an episode left half-heard restarted from the top. Nothing failed, nothing logged. Verified fixed on a device: a fresh episode now starts at the offset and a stored position resumes where it was left. - **Finishing an episode has to stop playback when nothing is queued.** `onFinished` used to rely on the file having ended anyway. With an outro offset the episode is finished while audio is still playing, so it marked the episode played and then played the credits regardless -- the exact thing the offset exists to prevent. - **XML comments cannot contain `--`.** The house prose style uses it constantly and it makes the manifest unparseable, with a merge error that doesn't name the line. - **The playing episode is always the head of the queue.** `advance()` drops the head, so if something played from a show list isn't there, finishing it deletes a queued episode unplayed. `play()` calls `playNow()` first to hold that invariant. Don't add a playback entry point that skips it. - **Two reachability tests guard dead code**, one for helper objects and one for DAO methods. Both exist because code was written, tested, documented and never called: `findFeedLink` (the share-sheet bug) and `QueueRepository.advance` (the queue silently stopped). Adding to their allowlists needs a reason, not just a name.