summaryrefslogtreecommitdiff
path: root/app/build.gradle.kts
AgeCommit message (Collapse)Author
4 daysShows have a category, not a tierPeter Stone
Tier said "how important" and the shows needed "what kind". Seven ordered categories replace it: news, analysis, curiosity, sailing, true stories, fiction, deep dives. The order is still the server's processing priority, so one field carries both axes -- which is the idiosyncrasy docs/show-kinds.md is written to watch, with four signs that a better abstraction has arrived. All 42 known feeds are refiled by migration 20 -> 21, which rebuilds the subscriptions table rather than renaming the column: RENAME COLUMN keeps the old 'ordinary' DEFAULT and fails schema validation, and DROP COLUMN needs SQLite 3.35 against minSdk 29's 3.28. The wire value for true stories is "truestory", not "true", because SQLite reads an unquoted true in a DEFAULT as the boolean and stores 1. Schema 21 is a new exported JSON; migrate20To21RenamesTierAndRefilesTheKnownShows covers it, and all 22 device migration tests pass. The coverage exclusion for the settings composable follows the file's rename; left pointing at TierSettingKt it read as a 0.6% drop. The remaining 0.08% is the deletion of the covered tier-seeding code, taken as an exception. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
10 daysDelete on-device detection. Detection is the server'sPeter Stone
Peter's call: server first. The audio-fingerprint subsystem ran on the phone until detection moved to the server, and then kept being shipped: FingerprintWorker and MidrollWorker went at the time and the twelve classes they drove stayed, unreachable, in every APK since. ReachabilityTest found it on 2026-11-03 once it was widened to classes, and it has been sitting in that test's allowlist with its reasoning since. Gone: IntroOutroDetector, AudioCorrelation, MidrollScanner, MidrollDetector, MidrollUpdater, FingerprintUpdater, MediaCodecClipSource, Decimate, Fft, MonoResampler, PcmCodec, StoredClip, and the 67 unit tests over them. 3,092 lines. **Recoverable by name, not by archaeology.** The tag `on-device-detection` marks the last commit that has it, pushed to the local remote along with everything else from this session, and the tag message says how to bring it back. The work cost weeks and the approach may be wanted again. What stayed, because none of it is detection: SkipSummary words the readout, BreakTrack and BreakPopup draw and explain the timeline's marks, and MidrollClipStore shrank to deleteAllFor alone -- its write and read halves had one caller each and both were the deleted updater, but a phone that ran the old version still has clip files on disk and unsubscribing is the only thing that clears them. Its test now writes the old layout by hand, because that layout is a compatibility concern now rather than this class's own choice. IntroOutroDetector.CONFIRM_THRESHOLD and MidrollDetector.CONFIRM_THRESHOLD moved to SkipSummary, the only thing left that reads them. Both were three and the server's DefaultConfirmThreshold is also three, checked against each other before the move. **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. Four MidrollDao methods went because they were the discovery half (upsertCandidate, deleteCandidate, getUncheckedDownloaded, setMidrollCheckedAtMs) and the reachability check named them; a DAO method is not schema, so there is no migration here and nothing for a real library to go wrong over. Two things found while doing it, neither caused by it, both written up in CLAUDE.md: - **The per-show "Detected skips" readout can never say anything.** It reads candidate and confirmation rows, and nothing has written one since detection moved -- the server writes per-episode ranges and offsets instead. Verified on the emulator, not inferred: a fresh install with a live subscription shows "Nothing found yet / Nothing found yet / None found yet" while the server is finding and publishing breaks for that very show. It has been lying since the migration; deleting the detector only makes it undeniable. - **RetentionSweeper's pending-episode guard protects nothing**, for the same reason: nothing sets a pending basis any more, so both id sets are always empty. The APK barely moves -- 110KB of dex, 72.61MB to 72.50MB -- because it is dominated by the embedded Python. This was never going to be a size win and is not being sold as one; it is 3,092 fewer lines to read, search and mislead. 1004 tests pass, detekt 0, lint clean. Coverage 95.35% -> 94.58%, which is the documented COVERAGE_EXCEPTION case: deleting dead code that happened to be well covered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
11 daysLook again for a waiting episode, and place a library nobody has placedon-device-detectionPeter Stone
The last two pieces, both of a kind: a feature that was built, wired and correct, and did nothing because the thing that triggers it was missing. **"Waiting for server" lasted until the next refresh.** The server starts fetching the moment it is asked and its on-demand mirrors finish in seconds -- 183 of them over thirty days, several completing two or three seconds apart. The phone looked again only on a feed refresh, which is the 5am alarm, a launch or the button, so an episode the server would have within the minute read "waiting for server" for hours. That gap, not anything on the server, is what made "not checked yet" a state a listener sees. A wait now schedules one look back a minute later, through WaitingDownloads, which already bounds the work two ways. KEEP rather than the REPLACE downloadNow uses: several episodes landing in the waiting state one after another is the normal case, and REPLACE would push the retry a minute further out each time until a queue of them starved it. The first wait sets the time. The hook lives on ServerSkips rather than on a wrapper around it. The wrapper version was written first and is the sort of thing that rots silently: changing the graph's declared type back to ServerSkips drops the override, nothing fails, and the retry simply stops. WaitForServerWiringTest now pins that a wait is reported through the real class, and was watched failing without it. **Tiers were shipped empty.** The control, the push on every refresh, the server ordering its work by it -- all built, all tested, and not one of 43 shows ever placed, because placing them was a tapping job. So the server worked the library in feed order and the news waited behind whatever else turned up, which is the exact thing the feature was asked for. TierSeeder reads it off the feeds instead, checked against the real library before it was written. It puts Tangle, Up First, Science Friday and Wards Way in Topical; both Dan Carlin shows, Radiolab, Reply All and the bulk-published back catalogues in Whenever; and leaves the weekly middle -- 23 of the 42 -- alone. Peter named Up First and Tangle as the ones to hurry and Dan Carlin and Radiolab as the ones to let wait, so the rule agrees with the person it is for. Publication rate alone does not work, which is why there is a floor. Serial, LOW and The Outer Reach each measure a median gap near zero because a whole season went up at once: on rate they look more topical than the news and mean the exact opposite. A gap under half a day is a back catalogue, not a daily show. It is a seed, not a policy. It runs once, touches only shows still sitting at the default, and never a show placed by hand -- and it declines to spend its one run on a library with no episodes to judge yet, or a fresh install would be marked done having placed nothing. Afterwards the control in the show's settings is the only thing that decides. Verified on the emulator rather than argued: Darknet Diaries, fortnightly, placed itself as "Whenever" on a refresh with nothing tapped. RetryWaitingWorker joins the three WorkManager entry points already excluded from coverage, under the list's own rule -- the decisions are WaitingDownloads', and those are measured. 1071 tests, detekt 0, lint clean, coverage 95.29% -> 95.35%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
12 daysMeasure and test the view models the ui exclusion was hidingPeter Stone
The coverage gate excluded `**/ui/**` wholesale, justified as "Compose screens are impractical to unit-test". It also hid six view models -- plain Kotlin classes holding real decisions, every one at 0%, not one of them a screen -- behind a justification that only ever applied to screens. PlayerViewModel and ListenViewModel were 150 lines of untested logic the gate could not see. Done by a subagent; its coverage figure, test count and detekt result were re-derived here from the XML rather than taken on trust, and its two findings checked against the source. The exclusion now names each screen file instead of the package. All six view models go from 0% to 92-99%, the measured `ui` package to 96.6%, and the project to **95.17% against a 94.44% baseline** -- the gate passes with the exclusion narrowed and no COVERAGE_EXCEPTION. 96 new tests, suite 951 -> 1048. The tests drive the real SubscriptionRepository, PlaybackAdvancer, QueueRepository, DripReleaser and FeedResolver against the existing FakeDaos and MockWebServer, so the banners asserted are the app's own wording rather than a stub agreeing with itself. Four production seams, all behaviour-preserving: `UiGraph` and `ViewPreferences` over the graph, `PlaybackControls` over PlayerConnection (the same shape as the `Playhead` precedent), and a second constructor per view model with the `(Application)` one kept as a delegate. That last is load-bearing in a way nothing would catch: `by viewModels()` finds a constructor reflectively, so losing it throws on screen open with a green build and no failing test. ViewModelConstructionTest is the guard. Its first version checked for the literal ": AndroidViewModel" and so missed TranscriptViewModel, which wraps its supertype onto the next line -- a check that would have passed while leaving a seventh view model unguarded in silence. It now cross-checks the two ways of finding them against each other. The action and state objects are excluded on a different ground from the screens, and the comment says so explicitly so the list does not grow by analogy: they construct fine off a device, but every line jacoco counts in them is one Kotlin generated -- constructor, componentN, copy, equals, hashCode, toString. Measuring them measures the compiler. A data class that gains a method or an init block comes back off the list. Also, found while reviewing: detekt's `UnusedImports` rule is off by default, which is how 71 dead imports accumulated across nine files. EpisodeActions.kt was eleven lines of data class under forty of them -- Scaffold, SnackbarHost, LazyColumn and the rest, left behind when the screens were split per file, which made it read like a screen. The rule is on and the 71 are gone. Two in that file needed removing by hand: detekt whitelists `getValue`/`setValue` unconditionally because it cannot tell a `by` delegation from none, and that file has no delegation at all. 1048 tests pass, detekt 0 findings on a fresh report, lint clean, coverage 95.17%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-05Find why Tier 3 never confirmed, and port detection to GoPeter Stone
Peter's report: the phone runs hot, uses a lot of battery, playback takes a moment to start, and intelligent skipping has never done anything. **The hot phone and the dead detection were one bug.** The candidate search correlated 117 window positions against the whole of the other clip, re-transforming that unchanged clip every time -- 199 seconds per episode 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 was persisted until a comparison returned. So it was killed mid-flight, saved nothing, and was re-enqueued to start over, forever. IntroOutroDetectorScaleTest is the tripwire whose absence allowed this: the unit tests ran on 300-sample clips while production ran on 1,920,000. Fixed coarse-to-fine, plus AudioCorrelation.prepareTarget so the target clip is transformed once per search rather than 117 times. 10.6x, and a test asserts the chosen reference window is byte-identical to what a full-resolution search picks. **Midroll detection could never have run at all.** decodeFull buffered a whole episode at its native rate -- 434MB for a 13-minute episode, no largeHeap -- to produce 6MB of 8kHz mono. It threw OutOfMemoryError into a swallowing runCatching and the episode was then marked checked forever. MonoResampler now streams the decode so the peak allocation is the output, and maxDecodableMs skips a longer episode before spending anything and without marking it checked. **Detection is moving to a server**, Peter's call, in Go to match podkit and doot. server/ holds the ported core, verified three ways because this is the second port of this maths and the first shipped a normalization bug: numpy's literals extracted mechanically from AudioCorrelationTest, the FFT against a direct DFT, and the whole correlation path against a brute-force Pearson. On four real Radiolab episodes it confirms the intro on 4 of 4, at 48.0s, 8.65s, 8.65s and 65.15s -- the varying pre-roll, observed in working code at last. skips.SelfCheck verifies located segments against *each other* rather than against the reference, which is a different claim: correlation is not transitive, so two segments can each clear 0.6 against a reference and be unrelated. It rejected the outro on its first real run (r=0.746), withholding offsets that would have ended three episodes early. Offsets are published only when they verify. Also: the episode screen gets a working scrubber with ad-break marks and a declared-versus-measured length readout, queue rows open the episode instead of doing nothing, PrepareTrace logs playback-start timing against episode length for the still-open pause question, and -PphoneOnly drops the emulator's CPython from a sideload build (114MB -> 99MB). Full history and numbers in docs/detection-findings.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-28Correct durations from the file, and make the notification open the appPeter Stone
Two from Peter, one of which turns out to matter more than it looks. **The list said 22m, the player said 29:34.** The feed is lying, not the parser: Radiolab publishes Grass Apes as `00:22:05` and the file ExoPlayer opens is 29:34. Checked against the live feed rather than guessed. The seven and a half minutes between them are dynamically inserted advertising -- the feed declares 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 exists for. The player is the first thing in the app that sees the truth, so PositionWriter now writes it back, through a new narrow PlaybackStateDao. Once per episode rather than once per tick: this runs on the position timer for the length of an episode, and a lookup on every tick is precisely the per-episode work that does not scale. A two-second disagreement is left alone, being a container arguing with an encoder rather than a lie, and an unprepared player reporting nothing never wipes what the feed said. PlaybackStateDao is a split by meaning, not by count -- it owns the two things playback learns and writes back, the only writes in the app whose loss is unrecoverable. It also keeps EpisodeDao under detekt's interface limit, which the new query had pushed it over; the config's own comment argues against splitting a DAO to satisfy a count, so this splits it for a reason that would stand on its own. **Tapping the notification opens the app**, via setSessionActivity. It had none, so the transport buttons worked and the body did nothing, which reads as breakage rather than omission. Verified on the emulator against the real feed: Grass Apes stored as 1325000ms on subscribe, 1770553ms after twenty-five seconds of playback. The notification now carries `contentIntent=PendingIntent{... startActivity}` in dumpsys -- I confirmed the intent is attached and targets this app's activity, but did not simulate the physical tap. That same dump corroborates what I told Peter about the notification being Auto's doing and not ours: importance=2, sound=null, vibrate=null, category=transport. ExoPlayhead joins the coverage exclusions. It is four one-line getters over ExoPlayer that happen to live in PlaybackService.kt, so it read as covered by that entry while actually being measured -- it is the seam that makes PositionWriter testable, not logic. 540 unit tests, detekt and lint clean. Coverage 92.73% -> 92.96%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-26Extend Tier 3 fingerprinting to midroll ad breaksPeter Stone
Desktop-prototyped first, same discipline as the intro/outro half: three Darknet Diaries episodes spanning 2026 and one from 2017 were fetched fresh and cross-correlated. Beyond the show's fixed ~51s intro (present unchanged across all nine years), the same coarse-to-fine scan found a second, shorter segment recurring at a *different* timestamp in every episode's body and independently present in the 2017 episode too -- a real recurring house promo, not a coincidence of two fetches landing close in time. That two-episode, different-position, still-present-nine-years-later shape is exactly what a midroll break looks like and exactly what IntroOutroDetector was never built to find: it only ever compares fixed head/tail windows. Midroll detection means scanning a whole episode body, and a show can have several distinct breaks at once, not one fixed slot per end. - MidrollScanner: pure, single-resolution "find every segment two recordings share, wherever each has it" -- deliberately position-agnostic, built on the existing AudioCorrelation primitive rather than a new one. - Decimate: block-averaging downsample, the coarse half of a coarse-to-fine split. A brute-force native-rate scan across a full episode measured 30-160s per episode *pair* on a desktop CPU in the prototype; MidrollUpdater runs MidrollScanner once on ~8x-decimated audio to find candidates cheaply, then re-verifies each at full resolution with a single AudioCorrelation call -- both for a trustworthy score and to reject anything the decimation made look like a match but isn't. - MidrollDetector: IntroOutroDetector's confirm/discard state machine, generalised from one candidate per show to a list. A candidate an episode doesn't confirm is discarded outright, same discipline, same reason: the Darknet Diaries trap applies here too. - MidrollUpdater: orchestrates discovery and confirmation per show. Discovery compares the show's single rolling "pending" episode against whatever is newest; unlike ShowFingerprintEntity's pendingClipPcm, no audio is stored for it between runs -- a pending *whole episode* is tens of megabytes of PCM, not the few seconds an intro/outro clip costs, so the file is re-decoded from disk on demand and the pending slot just moves forward if it's since been reclaimed by retention. A real bug the tests caught: the same segment surfaces again every time the pending pointer rolls forward, which without dedup became a second row for content already tracked. Fixed by recognising a fresh discovery hit as already-tracked by *where* it landed in the new episode (MidrollDetector.allMatches), not by re-correlating extracted clip content, which two different coarse passes don't reliably extract the same sub-range of. - Storage: AppDatabase 7->8, a show_midrolls table (one row per candidate, not per show -- several breaks can be tracked at once) and an episode_midrolls table (the confirmed ranges detected per episode). EpisodeEntity gains midrollCheckedAtMs (per-episode, not a show cursor, so an out-of-order backfilled download still gets checked). SubscriptionEntity gains midrollPendingEpisodeId. - Playback: MidrollSkip mirrors AdChapters' shape exactly. PlayheadPolicy checks it unconditionally, same as intro/outro -- no per-show toggle, the confirmation threshold is the safety mechanism. PlaybackSources grows a midrolls() method alongside chapters() rather than threading a new DAO through PlaybackAdvancer's constructor, which would have pushed it over detekt's LongParameterList threshold for no real reason. - Trigger: MidrollWorker runs nightly while charging, not per-download like FingerprintWorker -- a full-body scan is too expensive to run eagerly, and unlike a download it never touches the network, so there is no wifi gate either. Peter's call. MediaCodecClipSource gains decodeFull, sizing the decode buffer from durationMs with a margin rather than the fixed 4-minute clip window decodeHead/decodeTail use. Coverage 91.70% -> 91.76%. Neither half of Tier 3 has run on a device this session -- see CLAUDE.md's known-gaps list. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sj3CoWEedM56bebByKgNxA
2026-09-26Tier 3 skip detection: audio fingerprinting, prototyped and builtPeter Stone
Closes the last deferred Phase 2 item. Desktop-prototyped first, per the design plan's own instruction, against three real shows (see the prototype findings recorded in memory): NPR Up First (a clean ~6s intro sting, r 0.96-0.998 across every pair tested), Tangle (a long 60-90s open/sponsor read, equally reliable), and Darknet Diaries (no real intro -- a segment that matched between two temporally-close episodes turned out to be a dynamically-inserted ad, confirmed by checking a 2018 episode that matched the same "intro" despite the multi-year gap). That last finding drove the actual design: IntroOutroDetector requires 3+ independently-fetched episodes to agree before trusting a candidate and auto-applying it, which is exactly what the Darknet Diaries ad would not have survived. The confirmation threshold is the safety mechanism, not just caution for its own sake -- and it's now a parameter, not a hardcoded assumption, after coverage review found the "still tentative" branch was unreachable at the original threshold. - AudioCorrelation: FFT-based normalized cross-correlation, a Kotlin port of the validated prototype. Tested against numpy/scipy-derived ground truth fixtures, not just self-consistency -- the prototype's own normalization bug (global mean instead of each window's local mean, producing a bogus 0.98 "match" plain correlation put at -0.02) is exactly what that guards against being ported here unnoticed. - IntroOutroDetector: the pure state machine (waiting/tentative/confirmed/ discard-on-mismatch), mirroring DripSchedule's shape. - MediaCodecClipSource: the one Android-only piece (MediaExtractor/ MediaCodec decode to mono 8kHz PCM), on the framework-adapter coverage exclusion list for the same reason PlayerConnection is. - Storage: AppDatabase 6->7, a new show_fingerprints table (one row per show per intro/outro, tracking the current candidate clip and how many episodes have confirmed it) and two new nullable EpisodeEntity columns. Tier 2's manual per-show offset remains the fallback -- PlaybackProgress itself is unchanged; DetectedSkip.kt is the entire integration surface. - Trigger: EpisodeDownloader gained an onDownloaded hook (every download route ends up there, manual or queued) firing a new one-shot FingerprintWorker per episode. Coverage improved (90.13% -> 91.70%): the new pure logic is fully tested, and closing two real gaps the coverage review surfaced (an untested first-two-episodes-disagree path, and ShowFingerprintEntity's hand-written equals/hashCode, needed because a ByteArray property breaks the generated one) accounted for more than the new Android-glue lines cost. Not yet verified on a device -- no phone connected this session. The MigrationTest 6->7 case and the actual decode/match pipeline both need a real run before this is trusted with real playback. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sj3CoWEedM56bebByKgNxA
2026-09-26Hide played episodes, and hand off to the video at the right momentPeter Stone
Two of Peter's asks. Played episodes are hidden by default. An episode you have heard is done with, and leaving it in the list buries the ones that are not. A global toggle on the show screen brings them back, so a mis-tapped "played" can still be found -- stored in DataStore, which has been a declared dependency since phase 1 with nothing using it. Android Auto always hides them regardless of the toggle: a car list is scrolled blind at a traffic light. Hand-off carries the position both ways. Out: "Watch from here" opens the video on its own site at the point reached. In: a link shared from YouTube with a timestamp on it was shared at that moment deliberately, so the episode starts there rather than at the top. WatchLink does both and is pure, because an unrecognised URL shape fails silently -- as a link that simply starts from the beginning -- and YouTube produces a lot of shapes. The button sits on the episode screen as well as the player. That was not the first design, and testing changed it: the player-only version is unavailable exactly when it is most wanted, because an episode YouTube declines to serve never starts playing and so never reaches the player at all. Verified on a device, round trip: youtu.be/dQw4w9WgXcQ?t=90 shared in stored a position of 90000, the episode screen offered Watch, and tapping it fired youtube.com/watch?v=dQw4w9WgXcQ&t=90s. Marking an episode played removed it from the list, the toggle brought it back, and the preference survived a restart. An ordinary podcast offers no hand-off, having no page to open. Two things caught on the way. A crash: the episode cache was declared after the init block that collects from a StateFlow, which emits synchronously, so it was still null on the first emission -- caught by the launch check, now a plain field declared with the others. And the played switch needed a contentDescription distinct from its visible label, or it is ambiguous to pick -- the same trap the drip switch hit. Preferences joins the coverage exclusions: a Context, a file and a generated Flow, deciding nothing. WatchLink is fully covered. No permission changes, no schema change. 442 unit tests green, detekt and lint clean, coverage steady at 90.13. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-26Skip marked breaks by default, and drop the instrumented-coverage halfPeter Stone
Two things. Ad-chapter skipping is on by default, at Peter's request after living with it. It shipped off out of caution, being the one part of the skip feature that removes audio nobody has heard -- but it only ever acts on sections a publisher explicitly marked as not content, never on a guess from a title, and doing that without being asked each time is the point. Migration 5 -> 6 moves existing shows rather than leaving the new default to apply only to new ones. The column's SQL default stays 0 while the Kotlin default becomes true. Room writes every column on insert, so the SQL default is only ever consulted by an ALTER TABLE that has already happened; changing it would mean recreating the table for a value nothing reads. Said so at the field. Two unit tests were leaning on the old default to mean "not opted in". They now say `skipAdChapters = false` explicitly, which is what they meant. Second: enableAndroidTestCoverage is gone again, a day after adding it. It was the "both" half of the coverage change, and measuring it made the trade obvious -- it instruments every class in the debug build, which cost 25MB on the APK (118MB -> 143MB) plus runtime probes, on every sideload, for a report that would be run rarely. I had already published one of those builds. The framework-adapter exclusions carry this on their own; they are what moved the number from 65% to 90%, and what makes a future drop mean something. No permission changes. 428 unit tests green, 6 migration tests on a device, detekt and lint clean, coverage 89.97 -> 90.12. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-26Exclude framework adapters from coveragePeter Stone
Peter's call. The gate had been overridden eight times running, every one a class whose whole body is a call into ExoPlayer, Media3, Room, WorkManager or Chaquopy -- none of which execute on the JVM. The last few overrides were the argument for doing this. Pulling completion decisions out of PlaybackService into tested classes lowered the number, because the tested part was already counted and the glue it left behind was counted too. Writing an instrumented test for a migration lowered it again, because jacocoTestReport measures unit tests only. A metric that punishes both of those is measuring how much Android glue the app contains, not how well its logic is tested. Same code, 65.04% -> 89.93%: 629 uncovered lines were adapters, 131 are real. What is left is honest -- page-fetch coroutines that need the actual host, a few data classes, and Room's Converters, which are plain Kotlin and genuinely untested. A drop from here will mean something. The list sits next to its reasoning in the build file, with the bar written down: "cannot execute off a device", not "awkward to test". QueueRepository, PlaybackAdvancer, EpisodeDownloader and ChapterFetcher all touch framework-adjacent things and all stay measured. Also added jacocoFullReport, which folds in instrumented coverage for when you want the fuller picture with a device attached. The commit gate deliberately does not read it: device data only exists after a device run, so including it would make the gate report different numbers for identical code depending on whether one happened recently -- worse than a narrow gate. One trap worth recording: Kotlin block comments nest. A KDoc mentioning a glob with `/*` in it opened a nested comment, so the closing `*/` only closed the inner one and the comment ran on until it hit a `*/` inside a later string literal -- producing syntax errors on innocent lines well below the real cause. No behaviour change. 425 unit tests green, detekt and lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-25Auto-queue rules: decide what happens to an episode as it arrivesPeter Stone
The first of Phase 2's three tracks. A rule is a set of conditions on a newly discovered episode -- show, title contains/excludes, duration range, age, season -- and an action: queue at the top or bottom, mark played, and optionally download it or pin it to a speed. Rules are ordered, individually switchable, and the first one that matches claims the episode. Every claimed episode records which rule fired and the account that rule gave, shown on the episode screen as `Handled by "News to the queue" - title contains "news"`. That is not decoration. Automation that fills a queue and cannot say why is the thing that makes people stop trusting it, so the reason string is tested as hard as the matching is. Three decisions worth keeping: Rules never fire on a first subscribe. That import discovers the entire back catalogue -- 2,753 episodes on a real feed -- and a catch-all rule would enqueue all of it. `applyEpisodes` takes a `report` flag and `subscribe` passes false. Verified live: subscribing to NPR News Now stored four episodes and queued none; deleting the newest and refreshing queued exactly that one, with its reason recorded. A rule's speed lands on the episode, not the show. Writing it to `SubscriptionEntity.playbackSpeed` would let one matching episode silently reset the speed of everything else the show publishes. Auto-downloads go through a new DownloadWorker rather than the refresh. A refresh should finish in seconds whatever the rules decided; a worker that spent twenty minutes pulling audio would be killed mid-flight with the feed state half written. The drainer caps a batch at ten and leaves a failure FAILED rather than retrying forever against a 404. Schema v1 -> v2, this app's first migration, with MIGRATION_1_2 validated on a device against the exported v1 schema and a seeded playback position read back afterwards. Two pieces of plumbing were needed and both fail obscurely: the schemas directory wired into androidTest assets, and kotlinx-serialization raised to 1.8.1 because Room 2.8's bundle parser calls a method 1.7.3 lacks and dies with AbstractMethodError at runtime. Both are written up in CLAUDE.md, along with the trap that cost the most time -- changing an entity before bumping the version rewrites the exported schema for the old version, destroying the only record of what is on the phone. No permission changes. 298 unit tests green, detekt and lint clean, coverage 61.60 -> 63.63. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-25Add single videos, make the whole episode row tappable, stamp the buildClaude
Sharing a YouTube video now adds that one video rather than subscribing to its channel -- sharing something interesting is not a request to follow everything the channel ever posts. They land in one "Saved videos" container instead of a subscription each, which would otherwise fill the library with entries that will never publish again. Title, artwork and duration come off the watch page we already fetch, so adding needs no extractor. Only the episode title was tappable, which is too fine a target: Peter tapped the row, nothing happened, and reasonably concluded the details screen wasn't there. The whole row opens it now. Settings shows the commit and build time. A cached browser download and a failed publish look identical from the outside, and "is this the new build?" had no answer that did not amount to trusting me. publish-apk writes a companion .txt with the hash for the same reason. Metadata is entity-decoded. A YouTube thumbnail URL carries query parameters and the raw attribute encodes the separators as &amp;, so the artwork simply failed to load -- spotted in the real resolved output rather than by a test. YouTube URL handling moved to its own YouTubeUrls object. Channel, playlist, handle and watch URLs each need different treatment and two only resolve by reading the page; that is its own concern, not a corner of the general resolver. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-25Sign debug builds with a shared, checked-in keystoreClaude
"App not installed" when updating. Android generates a debug keystore per machine, so tungsten's build and the laptop's build are signed differently and each refuses to install over the other. Reproduced exactly: INSTALL_FAILED_UPDATE_INCOMPATIBLE, "signatures do not match newer version". The published APKs show the collision plainly -- 0930d0 on the 21st, 106ecafe in between, 0930d0 again after my last publish -- so an update had roughly a coin-flip chance of being installable depending on which machine built it. The checked-in key is tungsten's existing debug keystore, stock alias and password, not a freshly generated one. That matters: a new key would have been just as unusable as the mismatch, forcing a second uninstall and a second loss of every playback position, right after Peter had already done one. Verified by installing the currently-published APK and updating in place over it. Safe to commit -- it signs debug builds of a personal sideloaded app. It must never sign a release, where the key is the app's identity. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-23Add artwork, a real player screen, and queue drag-reorderPeter Stone
Closes three of the four Phase 1 gaps in CLAUDE.md's "Known gaps": artwork was parsed and passed to the lock screen but never rendered, playback had a transport bar with no scrubbing or per-show speed, and QueueOrdering.move was tested but unreachable from any screen. Android Auto real-car verification stays deferred, per Peter's call. - Artwork: new Artwork.preferredUrl (episode image falling back to the show's), resolved once at EpisodeEntity.toMediaItem's MediaMetadata boundary so the lock screen, Android Auto, the now-playing bar and the new player screen all read it from one place. Coil3 wired to the app's own OkHttpClient. Pinned to 3.3.0: later releases require a newer Kotlin stdlib or Compose than this project runs (Compose 1.12 needs compileSdk 37, one above what AGP 8.13.2 supports). - Player screen: scrubbing (PlayerConnection.seekTo, committed on drag release) and per-show speed (PlayerViewModel.setShowSpeed persists SubscriptionEntity.playbackSpeed via a new SubscriptionDao.updateSpeed) -- the field existed since Phase 1 with nothing to reach it. Opened by tapping the now-playing bar's title/artwork; Up Next moved to its own icon there. - Queue reorder: drag-and-drop via sh.calvin.reorderable, backed by a new QueueRepository.reorder that writes the final order once on drag release rather than per drag step (the gesture reports intermediate positions continuously). Coverage baseline drop (62.10% -> 61.15%) is entirely in already-0%-covered Media3 glue -- see COVERAGE_EXCEPTION. The new pure logic (Artwork, QueueRepository.reorder) is fully covered by new tests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sj3CoWEedM56bebByKgNxA
2026-09-21Android Auto support, and give the car its skip-forward buttonClaude
Two things from the car. Skip-forward was missing: a head unit shows about three transport controls, and leaving previous/next track available meant they took the slots. Previous does nothing useful for a single episode anyway, so those commands are now removed in onConnect and the seek buttons get the room. Auto did not recognise Listen as driving-friendly, so it could only be controlled from the phone. That needs a browsable library, which means a MediaLibraryService rather than a MediaSessionService, plus a tree: Up Next and Podcasts at the root, shows beneath, episodes beneath those, capped at 100 because one real feed here holds 2753. Three things were each individually fatal and none failed loudly: - The automotive_app_desc meta-data has to sit at application level. A search-and-replace put a copy inside each activity as well, where it is simply ignored. - The service must be exported, because the controller is another process. - onConnect must grant DEFAULT_SESSION_AND_LIBRARY_COMMANDS. With plain DEFAULT_SESSION_COMMANDS a browser connects successfully and then gets PERMISSION_DENIED on the library root -- the connection looks healthy and browsing is dead. Found by logging the result code rather than assuming. Voice search too, since in a car it is the only interaction that keeps your eyes on the road. Shows rank before episodes: asking for a show by name is the common case, and a show is browsable, so the driver still picks the episode. MediaBrowseTest drives all of this over the real session protocol from a MediaBrowser. It must run on the main thread and await rather than block -- MediaController rejects calls from another thread, and blocking the main thread on a future that completes there deadlocks. Also: XML comments cannot contain "--", which made the manifest unparseable with a merge error that never named the line; and the reachability guard was reporting a method reference (LibraryTree::subscription) as dead because it only matched call syntax with parens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-21Data layer: schema, episode matching, conditional-GET refreshClaude
Episode identity is the part worth care. Publishers re-host and regenerate guids, and the naive result is a duplicate episode and a listener who has lost their place in a three-hour recording. EpisodeMatcher falls back guid -> enclosure URL -> title+date, claims each stored episode at most once, and carries position, played state and download state across untouched. Title alone never matches, since a weekly show reuses one. Refresh sends stored ETag/Last-Modified so the common case -- nothing new -- is a bodyless 304 rather than re-downloading every feed on the phone. A 304 is a distinct outcome, not an empty feed, and absence from a feed page is never treated as deletion: feeds are routinely truncated. Failures are recorded on the subscription, not logged. A feed that has quietly stopped working is otherwise indistinguishable from one with no new episodes, which is the failure mode that has bitten every project in this workspace. Note on process: SubscriptionRepository was written before its tests, against the rule in CLAUDE.md, and all 11 passed first run -- exactly the symptom that rule exists to catch. Rather than assume they were meaningful I mutation-tested them: removing the failure recording is caught by two tests, and misreporting a 304 as an update is caught by one. A third mutation went uncaught but turned out to be inert, since the matcher has no delete path at all. No destructive migration fallback. Playback positions and play history are not reproducible from anywhere, so a missing migration must fail loudly rather than silently wipe the library. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-21Embed yt-dlp via Chaquopy instead of shelling out to TermuxClaude
Verified end to end on an emulator: the probe resolves a real video and the download lands 54,006,240 bytes of m4a, matching the size the probe predicted. Two traps found by running it rather than reasoning about it: - Chaquopy defaults to CPython 3.8, which yt-dlp no longer supports, so pip silently resolves 2024.10.22 -- the last release that did, nearly two years stale, and YouTube extraction does not work with it. The build succeeds and the feature is dead. Pinned to 3.12, which gets 2026.8.19, and YouTubeAudioTest now fails on any pre-2025 yt-dlp so it cannot regress quietly. - Chaquopy 16.1.0 calls org.gradle.util.VersionNumber, removed in Gradle 9, so Gradle is pinned to 8.14.3 here while FeeDreader stays on 9.5. AGP stays at 8.13.2. Chaquopy 17 requires AGP 9.x, so moving off this means bumping all three together. yt-dlp's JSON is parsed through a separate, unit-tested mapping rather than inline in the Chaquopy call. It is a separate project on its own release cadence and is the boundary most likely to drift, so a field going missing or changing type should fail a test rather than crash on the phone. Parsed with kotlinx.serialization because org.json is an empty stub off device, which would have made the mapping untestable. Audio-only by design: separate streams need ffmpeg to merge, the documented yt-dlp failure mode on Android, and a pre-muxed audio stream has nothing to merge. No ffmpeg dependency at all. No proof-of-origin token support, deliberately -- obtaining one means running Google's attestation script in a WebView, a different act from downloading, and it is not needed from an ordinary connection. Known risk recorded in CLAUDE.md: yt-dlp has deprecated YouTube extraction without a JavaScript runtime, and there is no deno or node on Android. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-21Scaffold Listen, with a podcast-native feed parserClaude
Sibling to FeeDreader, not a shared module. That was a considered call: its FeedParser only inspects <enclosure> while hunting for a thumbnail, and its FilterRuleEngine is 46 lines of title substring matching that doesn't generalise to queue rules, so only Opml.kt would genuinely have transferred. The parser is the piece everything else sits on, so it goes first, test-first: 18 tests covering audio/video enclosure selection, iTunes durations in both second and clock form, season/episode numbers, podcast:chapters for the skip feature, RFC 5005 archive paging for drip subscriptions, and episode identity falling back to the enclosure URL when a guid is missing. Two things worth recording: - Built on XmlPullParserFactory rather than android.util.Xml. The latter returns null off-device, which is why FeeDreader's 297-line parser has no tests at all. Both resolve to the same KXmlParser on Android. - SimpleDateFormat with isLenient=false rejects "Tue, 02 Sep 2026 06:00:00 GMT" -- an entirely ordinary RFC 822 date. Left strict, every pubDate in the app would have been null and episode ordering would have been silently wrong everywhere. minSdk is 29, deliberately not FeeDreader's 36, which would restrict the app to Android 16 and newer. Permissions: INTERNET and ACCESS_NETWORK_STATE for feeds; FOREGROUND_SERVICE, FOREGROUND_SERVICE_MEDIA_PLAYBACK, POST_NOTIFICATIONS and WAKE_LOCK because Android requires a foreground service with a notification to play audio in the background. That notification is the transport control, not an alert -- Listen posts no new-episode notifications by design. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B