# 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. **Phase 2 is in progress**, in three tracks. Peter deferred the audio-fingerprint skip detection (the design plan's Tier 3); the simple skip work comes 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 — not started.** Manual per-show offsets (`skipIntroMs`/`skipOutroMs` exist on `SubscriptionEntity` and nothing reads them) and Podcasting 2.0 chapter JSON via `EpisodeEntity.chaptersUrl`, which the parser already captures. **Known gaps in what is built** (deliberate, not forgotten): - **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. - **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. - **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` can wipe the app's data.** A run cleared `listen.db` on the emulator mid-session. It is the test harness's install path, not a migration: a real v2 database seeded with a subscription, an episode at position 424242 and a rule was upgraded in place by launching the v3 build, and all three survived with drip defaulting to off. Do not read an empty database after an instrumented run as a migration failure -- but do check. - **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. - **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.