1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
|
# 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 `<enclosure>` 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`, 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 `<uses-permission>` 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 — is built, in two parts.** Both were desktop-
prototyped against real episodes before any Android code, per the design plan's own instruction.
- **Intro/outro** (`IntroOutroDetector`, `AudioCorrelation`, `MediaCodecClipSource`,
`FingerprintUpdater`): one fixed candidate per show per end, needing 3+ independently-fetched
episodes to agree before it is trusted -- the confirmation threshold exists because two episodes
fetched close together can agree on a segment that is a dynamically-inserted ad, not the show's
real intro (found prototyping against Darknet Diaries). Triggered per download via
`FingerprintWorker`.
- **Midroll ad breaks** (`MidrollScanner`, `MidrollDetector`, `MidrollUpdater`): a show can have
several distinct midroll breaks, at a different timestamp in every episode, so this scans a
whole episode body rather than a fixed head/tail window -- `MidrollScanner.findSharedSegments`
finds every segment two episodes share, at whatever position each happens to have it, and
`MidrollUpdater` composes a coarse (decimated) pass with a native-resolution verify so that scan
is fast enough to run on a phone. Prototyping this against Darknet Diaries found a real recurring
segment (not the fixed intro) independently present across episodes nine years apart. Runs
nightly while charging (`MidrollWorker`), not per-download: a full-body scan is too expensive for
that, and unlike a download it never touches the network. No per-show toggle, same as
intro/outro -- the confirmation threshold is the safety mechanism.
**Both halves have now run on a device**, and doing so found a blocking defect the unit tests
could never have caught. `show_fingerprints.pendingClipPcm` held a whole decoded head/tail
window -- 240 seconds at 8kHz is 3.84MB as 16-bit PCM -- and Android reads every Room row
through a CursorWindow, which refuses a row over 2MB *on the way out*. The insert succeeded;
the next read threw `SQLiteBlobTooBigException`. Intro/outro detection therefore crashed the
app the moment a show's second episode was fingerprinted, and could never have worked as
shipped. Fixed in migration 9 -> 10 by storing only `pendingEpisodeId` and re-decoding on
demand -- the pattern `midrollPendingEpisodeId` already used for the same reason.
`StoredClip` now owns that limit, `MidrollUpdater` caps a candidate segment at 60 seconds
before persisting it (a long sponsor block would otherwise hit the same wall), and
`StoredClipSizeTest` pins both against the limit so the class of bug cannot come back quietly.
What is proven on hardware: the MediaCodec decode loop runs, `FingerprintWorker` completes
SUCCESS per download, and the re-decode path is exercised. What is **not** yet proven is that
detection actually confirms anything -- three downloaded Up First episodes left both ends still
"Nothing found yet". That may be correct (the confirmation threshold is deliberately strict) or
it may mean the 8-second reference window is too wide for a ~6-second sting; the desktop
prototype is the thing to compare against, and nobody has.
**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.
- **Tier 3 has never confirmed a real intro or outro.** The pipeline runs on hardware and the
worker succeeds, but three consecutive Up First downloads produced no candidate at either
end. Untested hypothesis: `FingerprintUpdater.DEFAULT_REFERENCE_SECONDS` is 8 and the
prototype measured Up First's sting at about 6, so every 8-second window compared also
contains two seconds of different speech. Do not change that constant without re-running the
desktop prototype -- it is tuned, and the confirmation threshold is what keeps a rotating ad
from being learned as an intro.
- **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 ExoPlayer opens is 29:34. The seven and a half minutes between
them are 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. `PositionWriter` replaces the stored value with the player's
once per episode, on the position tick, via the narrow `PlaybackStateDao`. Once per episode,
not once per tick: a lookup on every tick for the length of an episode is the per-episode
work the feed-scale note below warns about.
- **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
- **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.
- **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 button lives on the episode screen as well as the player, 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.
|