summaryrefslogtreecommitdiff
path: root/CLAUDE.md
blob: f97e524ee7c85b31132e32219ebff0f1843d116c (plain)
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
# 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
```

**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 `<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.

## 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, not started:** the auto-queue rule engine, slow-drip subscriptions, and intro/outro
skipping. `skipIntroMs`/`skipOutroMs` exist on `SubscriptionEntity` and nothing reads them.

**Known gaps in what is built** (deliberate, not forgotten):

- **No artwork anywhere.** The parser captures `imageUrl` at channel and item level and the
  media session passes it to the lock screen, but no screen renders it. Needs an image-loading
  dependency (Coil) and caching.
- **No player screen.** There is a transport bar only: play/pause, skip, title. No scrubbing,
  no per-show speed (the field exists on `SubscriptionEntity`, only the global is reachable).
- **No queue reordering.** `QueueOrdering.move` and `QueueRepository.move` are written and
  tested with no UI, which the reachability guard tolerates because they are on a class rather
  than an object.
- **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.

- **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.

- **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.