# 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 *as a substitute for that* — a test written against working code confirms what the code does, not what it should do, so it cannot stand in for a red step. The one case it is right: **pinning behaviour before moving it.** A characterisation test written against the old location, confirmed green, then re-run after the move is what makes a refactor provably behaviour-preserving — the "keeping it green" in step 5 needs a green to keep. Write it in that order and say so; written afterwards it only proves the new code agrees with itself. **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/go-verify /workspace/listen/server /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 0. **Server** — `/workspace/devtools/bin/go-verify server` passes: gofmt clean, builds, vets, and every test runs. First because it is the fast one, ten seconds against several minutes. It had no gate at all until 2026-10-31. Twenty-five server commits went in on somebody remembering to type `go test` and reading the output — the exact habit the Gradle wrappers exist to replace, in the half of the repo nobody had wrapped. `go-verify` reports from exit codes and parsed `go test -json`, and catches the two failures that look like success in a terminal: a package whose tests fail to compile (the count silently drops) and a run where nothing executed at all. 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`, less Compose screens and the framework adapters listed in `app/build.gradle.kts`. Set `COVERAGE_EXCEPTION="reason"` for justified drops, e.g. deleting covered dead code. With the adapters excluded the number is about 90%, so a drop now means something; before it hovered around 65% and mostly measured how much glue existed. 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, with instrumented tests covering the media browse tree. (Counts are deliberately not recorded here -- they went stale at 207 while the suite reached nine hundred. `gradle-test` and `go-verify` report the current ones.) **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 episode screen's overflow menu, and opt-in skipping of breaks the publisher marked `toc: false`. Nothing is guessed from chapter titles. **Tier 3 skip detection — audio fingerprinting — was built on the phone, and is gone from it.** Deleted 2026-11-04, after `ReachabilityTest` was widened to classes and found that none of it had been reachable since detection moved to the server: `FingerprintWorker` and `MidrollWorker` went then, and the twelve classes they drove stayed, shipped in every APK. What went: `IntroOutroDetector`, `AudioCorrelation`, `MidrollScanner`, `MidrollDetector`, `MidrollUpdater`, `FingerprintUpdater`, `MediaCodecClipSource`, `Decimate`, `Fft`, `MonoResampler`, `PcmCodec`, `StoredClip` and 67 unit tests. **The tag `on-device-detection` is the last commit that has it** — `git checkout on-device-detection -- app/src/main/java/org/terst/listen/fingerprint/` brings it all back, and the detection it implemented now lives in `server/internal/skips` and `server/internal/signature`, prototyped and measured against real audio there instead. What stayed, and why, because none of it is detection: `SkipSummary` words the readout, `BreakTrack` and `BreakPopup` draw and explain the marks on the episode timeline, and `MidrollClipStore` shrank to `deleteAllFor` alone — a phone that ran the old version still has clip files on disk, and unsubscribing is the only thing that clears them. **The database was deliberately not touched.** `show_fingerprints`, `show_midrolls` and `episode_midrolls` are the server's columns now — `ServerSkips` writes its answers into exactly the columns the on-device detector used to, which is why playback needed no changes when detection moved. No migration, no schema change, nothing for a real library to go wrong over. Two things the deletion left standing. The first is fixed; the second is not: - **The per-show detection readout had nothing feeding it — fixed 2026-11-05.** It read `ShowFingerprintDao.get` and `MidrollDao.getCandidates`, the confirmation state the *on-device* detector kept, and nothing had written a candidate row since detection moved. So every show reported "Nothing found yet" for ever, including shows the server was actively publishing breaks for — seen on Peter's phone. It is counted off the episodes now (`detectedIntroMs`, `detectedOutroMs`, and the midroll ranges `ServerSkips` writes), worded by `SkipSummary.showState`, and it keeps the distinction that was the point: *not checked* and *checked, nothing found* are different answers. `show_midrolls` is consequently an entity nothing reads or writes; the table stays because dropping it needs a migration and nothing is gained by one. - **`RetentionSweeper`'s pending-episode guard protects nothing.** It reads `getAllPendingEpisodeIds` and `getAllMidrollPendingEpisodeIds` so retention cannot reclaim a file mid-detection. Nothing sets a pending basis any more, so both are always empty — harmless, but a stale row on an existing phone would pin that episode's file for ever. **ID3 `CHAP` frames are dropped, not deferred** — decided 2026-09-26 by measuring Peter's actual 61 subscriptions rather than guessing. Every feed's newest episode was fetched far enough to read its whole ID3 tag (declared size, not a guessed window — several are multi-megabyte, Cruising the Waves is 10MB of cover art). **Not one carries a CHAP frame.** The frames present are all artwork and metadata: APIC, TALB, TIT2 and friends. Exactly one show, Wards Way Podcast, publishes chapters at all, and it does so as Podcasting 2.0 JSON, which is already supported. The survey scripts are worth rerunning rather than re-deriving if this ever comes back up. **Ad-chapter skipping has nothing to act on in Peter's library yet.** That same survey: the one show with chapters has six navigable chapters and *zero* marked `toc: false`. The feature is on by default and correct; it is simply waiting for a publisher who marks breaks. **Known gaps in what is built** (deliberate, not forgotten): - **A feed can carry two `<...:chapters>` elements per item** — the podcastindex one with a `url`, and a podlove one with none. Buzzsprout emits both. The parser takes the first non-null url, so order does not matter; a naive last-wins read gets null and silently loses chapters. - **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 shipped off by default out of caution and Peter asked for it on after living with it (migration 5 → 6 moves existing shows); it is still per-show and still only ever acts on sections a publisher explicitly marked as not content. - **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. > **The three bullets below are history, about code that no longer exists.** They describe the > on-device detector deleted on 2026-11-04 (see the Tier 3 entry above and the `on-device-detection` > tag). Kept because the measurements in them were expensive and the same questions will come back > if audio fingerprinting is ever wanted again — but nothing here names a file in this repo any > more, so do not go looking for `DEFAULT_REFERENCE_SECONDS`, `MidrollUpdater.discover` or > `IntroOutroDetectorScaleTest`. The surviving instruction from all of it is the one that is not > about the app at all: tune detection against real audio off-device, never from the phone. - **Tier 3 had never confirmed anything, and there were two independent reasons, both now fixed.** Not the correlation algorithm -- a real production-algorithm simulation against real Up First audio confirmed cleanly at the app's actual defaults before any of this. Do not tune `DEFAULT_REFERENCE_SECONDS`, `MIN_SCORE` or `CONFIRM_THRESHOLD` from the app -- they were never the problem, the app cannot show you what a change costs in false positives, and the threshold is the whole safety mechanism. Full history, with numbers, in `docs/detection-findings.md`. **Reason one, retention (found 2026-09-29).** `RetentionPolicy`'s one-day grace period routinely reclaimed a show's pending episode's file before the next download arrived to compare it against, for a fast, promptly-listened-to show like Up First -- so the detector kept discarding its only candidate and restarting. Confirmed live on the phone. Fixed by protecting a show's pending episode from reclaim, for both mechanisms: `RetentionSweeper` takes a `pendingEpisodeIds` seam and `RetentionWorker` supplies the union. **Reason two, cost (found 2026-10-03, and on the evidence the dominant one).** The candidate search correlated 117 window positions against the whole of the other clip, re-transforming that unchanged clip every time: **199 seconds per end, measured on a desktop**, so minutes more on a phone. `FingerprintWorker` is a plain WorkManager worker, Android stops one at ~ten minutes, and nothing is persisted until a comparison returns -- so it was killed mid-flight, saved nothing, and was re-enqueued to start over. That is why Peter's phone ran hot *and* why no show ever got past its first processed episode; one cause, both symptoms. Fixed by coarse-to-fine search plus `AudioCorrelation.prepareTarget`: **10.6x faster, and `IntroOutroDetectorScaleTest` asserts the chosen reference window is byte-identical to what a full-resolution search picks**, so none of the speed came out of detection quality. **Neither fix is yet verified on a device.** - **`IntroOutroDetectorScaleTest` is a cost tripwire, not a benchmark -- do not delete it for being slow.** The unit tests prove the state machine on 300-sample clips; production hands the detector 1,920,000, and nothing measured that for two phases of work. It asserts a 60-second budget at the real sizes with the real constants. If it starts failing, the search got expensive again, and the consequence is a worker that burns ten minutes and persists nothing. - **Midroll detection could never have run on an episode of normal length**, found the same day and independent of everything above. `decodeFull` buffered the whole episode at its native rate (a 13-minute stereo episode: 145MB of PCM plus 289MB of mono frames live at once, no `largeHeap`) to produce 6MB of 8kHz output. It threw `OutOfMemoryError`, `runCatching` swallowed it, and `MidrollUpdater` then marked the episode **checked forever** -- so nothing was ever scanned, nothing would be retried, and the readout said "None found" rather than anything true. `MonoResampler` now consumes the decoder buffer by buffer and keeps only the output, and `Tuning.maxDecodableMs` (20 minutes) skips a longer episode before spending anything and without marking it checked. **Still outstanding:** episodes over that ceiling are not scanned at all. Lifting it means decoding candidate ranges on demand rather than the whole body -- `MidrollUpdater.discover`, `extractSegment` and `MidrollDetector.check` all currently want the entire native-resolution array. Left deliberately undone rather than half-done. - **`PrepareTrace` logs one line per playback start**, tagged `ListenPlayback`, carrying the episode's real length against the elapsed time to READY and to PLAYING: `prepare ep=abc len=1774s src=local ready=812ms playing=903ms`. It exists for an open question of Peter's -- a pause before playback that looks proportional to episode length -- which cannot be answered from the code: nothing in the play path scales with length, so it is either Media3's own preparation or CPU starvation, and those are indistinguishable from outside. The clock starts in `onMediaItemTransition`, the only hook both play paths reach (a tap in the UI goes through a MediaController and never calls the service's `play`). **Measured so far: ~440ms to READY for a 27-minute downloaded episode on the emulator**, with nothing else loading the device -- so the cost is real and not purely CPU starvation. Whether it scales with length is still open and needs two episodes of very different lengths; the log line carries both numbers, so ordinary use on the phone answers it. - **Do not re-test the missing MP3 seek table.** These files carry no Xing, Info or VBRI header, so Media3 has no seek table to read, and the obvious theory was that `Mp3Extractor` scans the file to build one -- length-proportional, matching the report exactly. **Disproven by measurement:** enabling `Mp3Extractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING` moved the mean not at all (404/531/386ms against 469/450/404ms). A first single sample of each had looked like a 38% win and was cold-start noise -- the spread here is wider than any effect, so one sample proves nothing. It would have been the wrong trade regardless: constant-bitrate seeking makes seeks approximate on a VBR file, and the playback position is the crown jewel. Full numbers in `docs/detection-findings.md`. - **Detection runs on a server now, and `server/` is part of this repo.** Go, deployed on tungsten as `listend.service`, behind Apache at https://listen.terst.org with a Let's Encrypt cert. Data in `/site/listen.terst.org/data` (`state.json`, `audio/`, `token`). It polls the phone's feed list, mirrors the newest 8 episodes per show, detects across all of them at once, and serves the offsets *and the audio they belong to*. `FingerprintWorker` and `MidrollWorker` are gone from the app. **Serving the audio is load-bearing, not a convenience.** Dynamic ad insertion is on every episode: Radiolab's Grass Apes declares 22:05 and the server was served 28:18. An offset measured on one copy is minutes wrong for another, so the phone has to play the bytes the detector examined. **The silent publisher fallback is gone, by Peter's decision (2026-10-17).** It used to be deliberate -- skips improve playback rather than being a precondition for it -- and that reasoning was retired once the server's copy became load-bearing for accuracy. With a server configured, an episode it cannot supply yet enters `WAITING_FOR_SERVER` and is retried on every refresh. Taking the publisher's differently-ad-inserted copy is an explicit action on the episode screen that sets `audioFromServer = false`. Do not reintroduce an automatic fallback for a transient failure. **Three cases still download directly, and must keep doing so:** YouTube and Facebook (they never touch the server), an unresolvable feed, and -- critically -- **no server configured at all**. Without that last one the app stops working for anyone who has not set a server up. **A rejected token is permanent, not transient.** 401 and 403 download the publisher's copy at once and raise a notice in Settings; 404, 5xx, a connection failure, an unparseable body and the server's own `pending` all still wait. The transport reports Body / Rejected / NoAnswer and holds no decisions. Before this split, a mistyped token meant *nothing downloaded, ever*, silently -- the earlier "every failure means the same thing to the caller" rule stopped being true the moment the consequence changed from "no skips" to "wait forever". **`audioFromServer` records which copy actually landed**, which is what makes refreshing skips safe: `SkipRefresher` re-queries only episodes whose bytes the server itself measured, six at a time. Server timestamps must never reach publisher audio -- that would seek into the middle of the programme, which is worse than having no skips. **The offsets go into the same columns the on-device detector wrote**, which is why playback needed no changes: `effectiveIntroMs`, `effectiveOutroMs` and `PlayheadPolicy` already read them. No migration either. - **The server detects the advertising, not the intro, and that is now deliberate.** The property that broke intro detection is what makes ad detection work: eight episodes mirrored within one minute share whatever ad campaign is running, identically. `detect.BestLags` finds where two recordings align with one cross-correlation over every lag -- not one correlation per window position, which is why whole episodes are now scannable at all -- and `detect.MatchedRuns` walks the extent of the agreement directly. **It is validated against a number the detector does not produce.** The feed declares the ad-free length, the file is longer, and the difference is the advertising. On Radiolab's eight mirrored episodes the scan accounted for **53:29 of the 59:39** implied, every episode between 71% and 105%. First time in this project that detection has been checked against external ground truth and agreed. **Detection runs once per batch, not once per poll.** `Show.DetectedBatch` holds the set of episode ids the last completed pass examined; `pollOne` skips detection when the batch is unchanged. Without it the server re-derived identical results every cycle -- 4.5 hours of work against a two-hour poll interval, so it never idled. The batch is the unit because every result depends on which other episodes it was compared against, so one arrival or one pruning must re-run all of them. `Episode.DetectedAtUnix` cannot serve as this gate: it is only set for episodes that appear in `result.Offsets`. **Do not filter breaks by partner count or run length.** It looks obviously right -- 40% of breaks have one partner and a 5s median -- and it is wrong: measured against the declared length gap it drops agreement from 67% to 43%. Detection already under-reports. Table in `docs/detection-findings.md`. **Do not lower `MinRunSamples` below 12 seconds, and never tune it without `MergeGapMs`.** Measured 2026-10-19 against a copy of the live data. An 8-second floor nearly doubles real detection on LeVar Burton Reads (10:30 to 19:43 against a 27:35 gap) and simultaneously makes Sleepy History find **174 minutes** of "advertising" in a show that has 48 seconds of it -- worse than the original 4-second floor. The two interact: a low floor lets noise fragments survive and the 60-second bridging then welds them into enormous spans. Measuring only the show that improves gets you the opposite of the right answer. Breaks are published as ranges into `episode_midrolls`, which `MidrollSkip` and `PlayheadPolicy` already read -- no playback changes, no migration. Two guards publish nothing rather than something wrong: an episode whose matches cover over 35% of it, and any single match over four minutes, are refused (a re-aired episode or a two-parter would otherwise be eaten). Both record the refusal, because "found nothing" and "refused what it found" are different states and conflating them is the readout mistake again. - **Episodes have words now, and the words are the best ad detector in the project.** Two sources, and they are not interchangeable. **A publisher's own ``** is exact and free. Eleven of 44 feeds carry one. It describes the publisher's *master*, so it is only usable where the show inserts nothing: measured across every mirrored show that publishes one, the transcript tracks the **declared** length every time, which means it aligns precisely when served and declared agree and is minutes out of step precisely when they do not. `transcript.Fits` makes that call from the two lengths rather than from the words. 38 taken, 19 declined. **Speech recognition** (whisper.cpp, `base.en`, installed at `/opt/whisper`) describes exactly the bytes the phone will play. Measured, not estimated: **10.3x realtime on six threads, 793MB peak** -- a 63-minute episode in 6m11s. The earlier guess of "roughly realtime" was wrong by an order of magnitude. **A declined transcript is kept as the master, not thrown away.** Declined means "this copy carries inserted advertising the master does not describe", which is precisely the episode subtraction works on. - **`transcript.Inserted` finds advertising by subtraction, and it is a measurement rather than a guess.** The master says what the programme is, the recognised copy says what was played, and the difference is the advertising by definition. Across the 18 episodes holding a master it accounts for **92% to 104%** of what the declared-versus-served length gap implies, finding every pod. Reading ad copy (`transcript.Spot`) scores 0-29% on the same episodes, and only the first is ever published. Four things it took measurement to get right, all of them documented in their own commits: anchors name the *start* of a matching run (getting that wrong skipped the programme's last sentence); a gap is partitioned line by line rather than refused when the master drifted (which was losing a whole pod on three episodes); whisper's `[music]` notes are trimmed from break edges but still shown; and a short line is judged on a three-word run, because "I'm Jack Rhysider" comes back as "Resider" and one misheard word in four hid the host's own sentence inside a break. - **Transcript breaks are published, and this is verified on a device end to end** (2026-10-31). Heavyweight's "Jonathan + Death of an Artist", breaks at 0-1:45 and 13:32-15:16: downloaded from the server, both rows landed in `episode_midrolls`, playback started past the pre-roll (20 seconds of playing put the position at 2:03) and the post-roll skipped to the end, marked the episode played and reset the position. They live in `store.Episode.WordBreaks`, **not** beside the audio scan's: `detectAds` rewrites `Breaks` from scratch on every pass and would wipe them. `Episode.AllBreaks()` serves the union, merging ranges that overlap or touch, with advertising winning where two disagree. **`transcript.Publishable` is the only thing that turns words into a seek.** It publishes subtraction only, refuses anything for an episode with no length gap to check against, and refuses a candidate set claiming more than 150% of what was inserted. - **Refetching a second copy is for slow-drip and leisure shows, not daily news.** Peter's steer, and the numbers agree: a second fetch minutes after the first came back 20 seconds different and subtraction found 21% of the advertising; another came back the same length to the second and found none. Insertion varies the *amount* per request, not the campaign. Gated to episodes four weeks old. - **Text shared across a show's episodes is the strongest unexploited signal, and it is free.** Eight episodes mirrored within a minute carry the same campaign and different programmes, so text appearing across several of them is the advertising -- the text analogue of the audio scan, using transcripts already on disk. Prototyped against the live library: Heavyweight 111%, Incels 105%, Ezra Klein 92%, Tangle 83% (against refetching's 21% on the same show), covering 21 shows. It also reaches the ~81 episodes with no length gap at all, where ads are baked into what the publisher serves and nothing else can see them. **It catches a show's own furniture too** -- Tangle's welcome, its credits, "we'll be right back after this quick break" -- so it needs the same intro/cue/outro/ad classification `skips.Classify` already does. Not built. - **Intro and outro offsets are computed, logged, and deliberately not published.** They assume the agreeing episodes were fetched independently, and mirroring eight at once means they were not. Proved, not suspected: the segment confirmed on 8 of 8 Radiolab episodes was searched for in an ad-free copy of the same episode fetched hours earlier and scored **r = 0.0621** -- it was in the ad break. `SelfCheck` cannot catch that, because the segments genuinely *are* the same audio. They become trustworthy once the agreeing episodes are required to span more than one fetch occasion; until then do not publish them. - **The subscription list is the one thing the server cannot rediscover, and it was being lost.** Three bugs, all found by reading the journal after a restart rather than by a test: the list was persisted only when a whole poll cycle finished (hours, for 61 feeds); a cycle re-applied its stale snapshot at the end, which would undo a push that arrived during it and delete the audio of every show the snapshot omitted; and detection ignored cancellation, so systemd's 90-second stop SIGKILLed the process before the save-on-exit could run. Now: saved on arrival, saved after every feed, reconciled against the live list, cancellation honoured, and `TimeoutStopSec=600`. - **`android-ui tap` can land on the soft keyboard.** The dump reports the layout's own coordinates, which the IME does not move, so an element listed at y=1643 can be underneath the keyboard -- taps on it silently do nothing, which reads exactly like a broken handler. Found the hard way: a Save button and a text field both appeared tapped and neither fired. Check `adb shell dumpsys input_method | grep mInputShown` and dismiss with `input keyevent KEYCODE_BACK` (not `KEYCODE_ESCAPE`, which does not close it) before tapping anything low on a screen that has a text field. - **The server's API token is alphanumeric on purpose.** It is typed into a phone by hand, and the first generated token was base64url -- whose leading `-` was eaten by `adb shell input text` treating it as a flag, producing a silent 401 that looked like broken wiring. The alphabet also omits l, I, 1, O and 0. - **Android Auto is verified in a real car**, as of 2026-09-28. It appears, and connects. The notification Peter saw on first connect was Android Auto's own new-media-app announcement, not Listen: the only notification this app can raise is the Media3 transport control, which a foreground media service is required to post and which is on a low-importance channel. What is still untested in a car is browsing and voice search. ## Domain notes worth keeping - **A feed's declared duration is often wrong, and not by rounding.** Radiolab publishes Grass Apes as `00:22:05`; the file served is 27-30 minutes. The gap is dynamically inserted ads -- the feed states the ad-free content length and the CDN stitches in the rest -- so the error is large, systematic, and worst on exactly the shows the midroll detector cares about. **Two columns, not one.** `durationMs` is the feed's claim and stays the feed's: `EpisodeMatcher.mergedWith` rewrites it on every refresh, correctly, which is why writing the measured length there lasted exactly until the next refresh. `actualDurationMs` is what the player measured and nothing else touches. **Read `playbackDurationMs`**, which prefers the measured one -- the feed's figure otherwise sizes the decode buffer a full-episode scan allocates, places the outro offset, and drives the finished check that would mark a 29-minute episode played at 21. `PositionWriter` records it once per episode on the position tick, via the narrow `PlaybackStateDao` -- not once per tick, which would be the per-episode work the feed-scale note below warns about. - **The difference between the two durations is the advertising**, free and with no audio analysis: `insertedAdMs`, shown on the episode screen. Peter's observation. It is also a check on the midroll detector, which should eventually account for roughly that much. **It is per *file*, not per episode** -- the same Grass Apes measured 7:29 of ads on one fetch and 5:34 on another, because insertion happens per request. Do not treat it as a stable property of an episode, and do not be surprised when a re-download changes the length. - **A timestamped link shared in starts playing there.** `WatchLink` parses the `t`, the repository stores it as the position, and the share activity hands the episode to `MainActivity.playIntent` so the app opens on that episode's screen already playing -- the point being to move between YouTube and Listen without hunting for your place. Re-sharing a video already in the library updates its position and unplays it, which is the case that was broken: the insert ignores a clashing guid, so every share after the first silently did nothing. A share carrying no timestamp still just adds it quietly, since interrupting whatever is playing would be rude. - **The media notification opens the app**, via `setSessionActivity` on the session. Without it the transport buttons work and tapping the body does nothing, which reads as breakage rather than omission. Worth knowing for diagnosis: `dumpsys notification --noredact` shows this app's notification as `importance=2`, `sound=null`, `vibrate=null`, `category=transport` -- it is a control surface, and it is silent. - **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 - **Apache does not log query strings for listen.terst.org, deliberately** (2026-11-05). Both vhost files define a `listen_noquery` LogFormat -- `%m %U %H` where `combined` uses `%r` -- so the request line reaches the log without its query. The reason is that `/audio/` URLs carry the server's token as a query parameter, because a URL handed to a downloader has to work as it stands, and `combined` therefore wrote the token in plaintext into a file logrotate keeps fourteen copies of. 335 lines across fifteen files; all redacted in place at the time, and the token is worth rotating on any suspicion since it sat there for months. **Do not set these back to `combined`.** If a line's query is ever genuinely needed for debugging, log it from the Go server, which can redact what it prints. The real fix is for the audio URL to stop carrying a token at all, which needs `EpisodeDownloader` to send `Authorization: Bearer` for skip-server hosts — and only those, or the token goes to every podcast CDN. The app change has to be installed *before* the server stops emitting the query token, or downloads 401 in the gap. The transcript URL already made this move: it never needed one, because the phone was sending the header anyway. - **Nothing the listener reads is written for a machine, and the build enforces it** (`PlainLanguageTest`, added 2026-11-05). A red banner on Peter's phone read "Playback failed: ERROR_CODE_IO_BAD_HTTP_STATUS"; his words were "arcane and sketchy" and both halves were fair. The same pass found a downloader printing `${it.message}`, an OPML import printing `permission denied`, and `FeedFetcher` putting the literal word `UnknownHostException` under a podcast's name in the library — **and two tests that asserted the machine text came through**, which is how it survived review. Causes go to logcat; the screen gets a sentence, from `PlaybackFailureText`, `FeedFailureText` or `FailureText`. Accuracy is the harder half of the rule: "it will be retried" would read better than "try the download button again" and would be a lie, because a failed download is deliberately not retried. - **Every outbound request goes through one client, and the build enforces that too** (`OutboundRequestsTest`). One `OkHttpClient`, built in `AppGraph`, carrying `SecureSchemeInterceptor`; everything else is handed it, Coil's artwork fetches included. The same arrangement the server has in `safefetch/audit_test.go`, for the same reason: this app fetches URLs chosen by podcast publishers. - **Playback plays the app's own copy, never the publisher's** (changed 2026-11-05). `toMediaItem` falls back to the enclosure URL when there is no local file, and that fallback was reachable from every play: pressing play on an undownloaded episode streamed it straight from the publisher's CDN. That is the automatic fallback the download path had removed on purpose; it is an unasked-for request to a third party; and because nothing in playback checks `audioFromServer`, the breaks the server measured on *its* copy were being applied to a copy with different advertising in different places. `PlaybackAdvancer.start` now returns `NeedsFetch` for any episode with no file, so the app fetches first. **One caller still reaches the fallback: Android Auto.** `onAddMediaItems` resolves a browse id straight to a media item without going through the advancer, and a car that silently plays nothing is worse than a car that streams. Fixing it properly means fetching inside the session callback. `OutboundRequestsTest` pins the fallback to exactly one line so it cannot spread back. - **The episode screen is the player; there is no separate player screen** (merged 2026-10-19). Opening an episode puts you in its transport at the stored position, and pressing play changes nothing about the layout. Playback state still has one owner, `PlayerViewModel`; the screen only reads `nowPlaying` and asks it to start, pause or seek. The now-playing bar opens the playing episode's screen, and steps aside on that screen. Speed is a chip (`SpeedChoices.OFFERED` is 1.0/1.1/1.25/2.0); a show stored at 1.5 or 1.75 keeps playing at it, the chip just names it. Secondary actions and chapters are in the `⋮` menu, whose contents `EpisodeMenu` decides, and the description shows about ten lines (`DescriptionPreview.PREVIEW_LINES`, with a character budget beside it) and the rest on tap. It was two until 2026-11-01: that limit existed to stop long notes pushing the transport off-screen, and the merge put the notes *below* the transport, so the reason had already gone. What it actually did was cut at 140 characters and leave the bottom three-fifths of the screen empty. Do not reinstate it. - **Framework adapters are excluded from coverage, as of 2026-09-26.** The gate had been overridden eight times, every one a class whose whole body is a call into ExoPlayer, Media3, Room, WorkManager or Chaquopy. The last few overrides were perverse: pulling decisions *out* of a service into tested classes lowered the number, and writing an instrumented test for a migration lowered it again, because `jacocoTestReport` measures unit tests only. A metric that punishes both was measuring how much Android glue exists. Peter's call, taken 2026-09-26. The list lives in `app/build.gradle.kts` next to its reasoning, and coverage went 65.04% → 89.93% on the same code. **Adding to that list needs a reason, not just a name** — the bar is "cannot execute off a device", not "awkward to test". Earlier, the gate firing was a useful prompt: it drove PositionWriter onto a narrow Playhead interface and pulled RetentionSweeper out of its Worker, both of which found real bugs. That value only survives if the list stays honest. Feeding instrumented coverage into the report as well was tried and abandoned: `enableAndroidTestCoverage` instruments every class in the debug build, which measured at +25MB on the APK (118MB → 143MB) plus runtime probes, on every sideload, for a report that would be run rarely. It would also have made the gate report different numbers for identical code depending on whether a device run happened recently. - **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 the page cannot name a shared video. `SubscriptionRepository` asks the extractor instead when the page gave no title, which is where the title and duration now come from. Only then: a title that was shared is the one that was meant, and the probe costs a network round trip. - **Per-show controls live behind the show's overflow menu**, in a bottom sheet, not stacked above the episode list where they used to be -- reaching the episodes meant scrolling past drip cadence and two paragraphs of explanation. The explanation went too, at Peter's request: this is a single-user app and he wrote the requirements, so labels are one line and nothing is taught. Unsubscribe moved into the same menu, off the top bar where it sat one tap from Refresh. - **Bedtime is a flag on a show, not a category system.** A bedtime show is absent from the ordinary library entirely and listed only in bedtime mode, which the moon icon toggles. That mode is deliberately *not* persisted -- waking to an app still in bedtime mode, library apparently empty, is the failure being avoided; a restart is morning. Entering it also arms the sleep timer, and `BedtimePolicy` owns the two cases that make that safe: it will not overwrite a timer set by hand, and leaving only cancels a timer bedtime itself armed. - **A show with nothing waiting is dimmed, not hidden.** Same condition the library sort already used -- nothing unplayed *and* unqueued. `hasWaiting` comes from a COUNT, not from the sort key: an episode with no publish date ranks at 0, which is right for ordering and would wrongly grey out a show that has one sitting unheard. The `lastError` line keeps its error colour through the dimming, because a feed failing to refresh looks exactly like a feed with nothing new. - **Anything the app says out loud lives in a named file of its own, and is tested.** Not in the composable that renders it: `SkipSummary`, `RuleSummary`, `FoundBySummary`, `WaitingForServerText`, `RejectedTokenText`, `DescriptionPreview`, `SpeedChoices`, `TimeText` (`formatClock` for a position, `formatDuration` for a length) and `EpisodeLabels` (`progressLabel`, the one line under every episode title). Screens are excluded from coverage and verified on hardware, so wording left inside one is wording nothing checks -- and a shared formatter inside a view means editing the now-playing bar to change the transcript gutter, which is where `formatClock` actually was until 2026-11-02. A new user-visible string either joins one of these or brings its own file. - **What the skip detection has worked out is visible**, per show in the settings sheet and per episode on its detail screen. `SkipSummary` owns the wording and is tested, because the distinction that matters -- confirmed-and-acting versus still-gathering -- is the whole point, and "not checked yet" is a third state again (midroll scanning runs nightly, so a fresh download legitimately has nothing to say). - **Played episodes are hidden by default**, globally, via a DataStore preference the show screen toggles. Android Auto always hides them regardless: a car list is scrolled blind at a traffic light, and something already heard is pure noise there. - **A video episode can be handed off to its own site at the current position**, and a link shared *in* with a timestamp starts there. `WatchLink` does both, and is pure so every URL shape is pinned -- an unrecognised one fails silently, as a link that starts from the beginning. The action lives in the episode screen's overflow menu, deliberately: an episode that cannot be fetched never starts playing, and that is exactly when watching it elsewhere is the point. - **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.