summaryrefslogtreecommitdiff
path: root/.coverage-baseline
AgeCommit message (Collapse)Author
3 daysThe player sends the token in a header too, not just the downloaderPeter Stone
The last thing standing between the token and the query string it should not be in. The downloader moved to an Authorization header a while back; the player never did, and the player is how the car plays anything not already on disk. Withdrawing the query parameter without this would have broken streaming in the car silently -- a 401 arriving as "playback failed". ExoPlayer resolves a URL on its own loading thread with no coroutine to read DataStore in, so ServerAudioAuth caches the configuration and the session callback refreshes it before handing over any stream URL. Nothing is cached per URL: the same player fetches publisher audio, and the whole point of ServerBearer is that those get nothing. Attached through a ResolvingDataSource rather than as a default request property for the same reason -- a default would send Peter's server credentials to every podcast CDN he subscribes to. Before the first refresh it sends nothing, which is the safe direction: a stream that should have carried the token fails visibly, where one that should not have carried it would have leaked it. Verified against the live server, not a fake: the player reaches READY on listen.terst.org audio with a header and no token in the URL, and the same URL without credentials is still refused. The token is supplied as an instrumentation argument and is not in the repository -- a test file carrying it would be the same mistake in a different file -- so the test skips unless one is passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
3 daysThe car groups shows by category, and a silent show says soPeter Stone
Two things, both about finding something rather than playing it. Android Auto lists category folders under Podcasts instead of forty-five shows in a row -- a car screen holds six to eight, so anything not near the top was several flicks away. The categories already existed and already had an order, so this costs no new data. With only one category present the folders would separate nothing, so the shows are listed directly and the level disappears. A show that has not published in a year says so above its drip control: "No new episodes in 3 years". That is the whole of the special treatment a finished show gets -- no flag, no automatic behaviour, no guessing that a show is over. A run of silence is a fact and "finished" is not. A year, because shorter fires on everything merely between seasons, and a line that appears on everything says nothing. It costs no extra query: the sheet already reads every episode for the detection readout. The browse id format moved out of LibraryTree into BrowseIds, because that object went past its function limit and the split is along a real line. The format is the seam that has actually broken -- an id that does not survive the round trip to the browser is what made Up Next show something other than the queue -- and it could not be tested off a device before, because LibraryTree builds Media3 items. It has six tests now, including that an episode id and a show id carrying the same raw string are never confused for one another. Verified over the real session protocol on the emulator: the folders appear, the News folder holds the news show and not the fiction one. 8 browse tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
3 daysThe car offers Continue, Short and NewPeter Stone
Browsing by show is the wrong shape for driving: it asks you to remember what you were doing and then find it, which was behind "discovery sucks" and "scrolling the lists in the car is bad experience". Three lists sit above the shows now and answer the question actually being asked at a traffic light -- what was I in the middle of, what fits this drive, what is new. All three come from data the app already keeps. No new column, no migration. Short cuts off at 20 minutes, which is Peter's call rather than a guess: it catches the daily news and the shorter curiosity episodes and leaves out conversation and deep dives. It reads COALESCE(actualDurationMs, durationMs) -- playbackDurationMs in SQL -- because a feed declaring 22:05 for a file that runs 28:18 would otherwise put a half-hour episode in a twenty-minute list. An episode of unknown length is left out; unknown is not short. Continue is the one list that does not exclude queued episodes, because the episode you are in the middle of is normally the head of the queue and excluding it would empty the list in the ordinary case. Short and New exclude anything started, so finishing something is never buried under a new arrival. A list holding nothing is left off the root entirely rather than opened and found empty -- the same dead end the phone's library dims a show for. The queries are their own narrow CarListDao rather than three more methods on EpisodeDao, which detekt was right to call already the widest in the app: they are read-only, they all cross every show rather than belonging to one, and they exist for a single surface. Not on UiGraph, since no view model wants them. Verified on the emulator: 9 CarListsDbTest cases against the real tables, and MediaBrowseTest walks the real session protocol to the Continue node and finds the part-heard episode in it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
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
4 daysThe car plays the queue again, and the lists get out of the wayPeter Stone
**Android Auto was broken by my own fix, and this is the third attempt at the same decision.** It started by falling back to the publisher's enclosure, which plays a differently-ad-inserted copy so the measured breaks cut into the programme. Fixing that made the browse callback *download* first -- tens of megabytes inside the future Auto is waiting on -- so the car reported "failed to load" and the episode played later only because the download had finished by itself. That is exactly what Peter saw. The distinction missed twice: streaming is not the problem, streaming *the publisher's copy* is. The server's copy is the audio the offsets were measured against, so playing it is as correct as playing the file and it starts at once. The car asks the server (one small request), plays the download if there is one, the server's copy otherwise, and nothing at all if neither -- never the publisher. The fetch still happens, on its own coroutine, so the next attempt has the file. OutboundRequestsTest said "no remote media item", which was the wrong rule and is why it had to be relaxed to let this through. It now says what was always meant: nothing in the playback package may touch `mediaUrl`, the address the *publisher* nominated. Watched failing. **The car's Up Next was not the queue, and one episode looped.** Both are the same bug, and not in the browse tree -- that listed the real queue all along. Tapping an episode set the *player's* playlist to that one item, so the car's own Up Next showed one entry and a single-item playlist repeats instead of moving on. The playlist is now the queue, starting at the episode tapped. An episode browsed from a show is not in the queue and still plays alone, because dragging the queue in behind it would quietly change what plays next. ReachabilityTest then found `startFetchingIfNeeded` had no callers left -- it existed only for the car path this replaces -- so it is gone, with the two tests that were really about `fetchAndStart` rewritten against it rather than deleted with it. **The lists, as asked:** - The library row offered play, queue, download. It now offers play next, queue, mark played. Play is a long press and download happens by itself for anything queued; what was missing was a way to say "not now" without opening the show. - Episode rows had five icons across a phone, where the useful ones were the hardest to hit. Three now, the same three. - Long press on an episode plays it immediately. Tap still opens it. - The show's name in the episode screen's top bar opens the show. It was sitting there doing nothing. Extracting `rememberShownEpisode` kept EpisodeScreen inside its length budget -- the follow-the- player logic is a named concern and reads better out of the way than the top bar did. All four checked on a device, not just in tests: the three icons, a long press starting playback at 0:06, and the title tap landing on the show. The coverage gate pushed back and found one more gap on the way: a fetch failing with no message fell through to the exception's class name, and nothing exercised it. Tested now. The exception covers only the deleted method's own tests. 1065 tests, detekt 0, lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
9 daysA feed must not be able to spend the server's tokenPeter Stone
The security review of the last commit found a confused deputy in it, and it was right. The bearer was attached on origin alone, and `episode.mediaUrl` comes out of a feed -- so a publisher could set an enclosure URL on the skip server's own origin, match the origin check exactly, and have the app issue an authenticated GET to a path of their choosing, storing whatever came back as that episode's audio. Confirmed by writing the test before the fix: it failed. Provenance is the first gate now and origin is the second. `authenticated = true` is passed only for the URL `ServerAudio.resolve` returned, never for one that arrived in a feed, and the origin check still has to agree -- so a server answer naming some other host is fetched without credentials rather than sending them there. Both gates have a test, including the case where they disagree, and OutboundRequestsTest pins that exactly one call site in the app is authenticated. **Chasing the coverage gate honestly turned up a real bug**, which is the second time this week it has earned its keep. Rather than file a third COVERAGE_EXCEPTION I went to see what the uncovered lines in EpisodeDownloader actually were, and wrote a test for the empty-response branch. It failed: OkHttp hands back an empty body rather than null for a 204, so the existing `?: run { ... }` guard never fires, the copy writes nothing, and a **zero-byte file is renamed into place and recorded as DOWNLOADED**. That episode then fails to play and reads as the publisher's fault. The length is checked after the copy now, which catches a 204, an empty 200 and anything else that arrives with no bytes. Also tested while there: the media-type to file-extension mapping, which had none. Not cosmetic -- Media3 picks an extractor partly from the extension, so a .audio file holding mp4 bytes is a plausible way to get an episode that will not play. Parameters and capitals included, because feeds send "audio/mpeg; charset=utf-8" and "AUDIO/MPEG". 1059 tests, detekt 0, lint clean, coverage 94.09% -> 94.28% with no exception. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
9 daysSend the server's token as a header, so the audio URL can stop carrying onePeter Stone
Groundwork for the server to stop putting the token in /audio/ query strings. Apache no longer writes those to disk, but a secret in a URL still travels through everything that handles the URL, and the only reason it was there is that the downloader had no credentials of its own. Now it does: ServerAudio gains bearerFor(url), ServerSkips answers it from the configured address, and downloadOverHttp attaches Authorization: Bearer when it gets one. **The whole risk is in that one question.** This downloader also fetches episodes from whatever CDN a publisher nominates, so an over-eager match would hand Peter's server credentials to every podcast host he subscribes to -- strictly worse than the leak being closed. ServerBearer therefore compares parsed origins, scheme and host and port, never the text of the URL, and the test is mostly near misses: a host the server's name is a prefix of, one it is a suffix of, the name appearing in a path or a query or a fragment, a userinfo section ending in the server's name before an `@`, a downgraded scheme, a different port. Several of those pass a naive string match. One of those tests needed a test of its own. `https://listen.terst.org@evil.example/...` is refused, but it would also be refused if the URL simply failed to parse -- in which case the origin check was never reached and the thing I thought I was testing was untested. So there is an assertion that it parses, with host evil.example and username listen.terst.org. The wiring test runs the real ServerSkips rather than a fake and checks the publisher path sends no Authorization at all, because the unit tests above it fake exactly the component whose host-matching is the safety property. The server still emits the query token; this has to be installed before that stops, or downloads 401 in the gap. That is the next change, not this one. 1052 tests, detekt 0, lint clean, coverage 94.06% -> 94.09%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
10 daysThe car plays our copy too, and the transcript URL stops leaking the tokenPeter Stone
**Android Auto was the one caller still streaming from publishers.** `onAddMediaItems` resolved a browse id straight to a media item without asking whether the audio existed, so an undownloaded episode was handed to the player as its publisher's URL. That is the worst place for it: the car is where most of this library is actually heard, and misplaced skips are least obvious there. It goes through `PlaybackAdvancer.startFetchingIfNeeded` now -- start, and fetch first if there is no file -- the same decision the in-app paths make, in the one class tested for it. A fetch that fails stays failed rather than falling back: the publisher's copy carries different advertising, so the server's measured breaks land in the wrong place, and silence that can be retried beats skipping into the programme. With that, nothing reached the fallback, so it is gone. `setUri(localPath ?: mediaUrl)` is now `setUri(localPath)`, `MediaUrl` is deleted, and OutboundRequestsTest asserts zero remote media items rather than pinning one. `start` also reports Gone for an episode whose show *and* file have both gone, which is what made the fallback removable -- an orphan that still has its file plays. **Two findings from the security review of the last commit, both mine, both real:** - `Request.Builder().url()` throws on a URL it cannot parse and only IOException was caught, so a feed with a junk enclosure threw straight past the Result its caller folds. Survivable when this ran from a worker; not now that every play goes through a fetch, where one bad enclosure in one feed would stop any episode starting. Caught in the downloader, and `fetchAndStart` turns a thrown fetch into FetchFailed -- rethrowing CancellationException, which a test now pins, because swallowing it would report a cancelled start as a failed one and leave a false error on screen. - `FeedFetcher` logged whole feed URLs. Private feeds carry a token in the query and some carry credentials in the userinfo. `FeedFailureText.redact` cuts them to scheme and host, and is tested as the security control it is rather than as tidiness. The unparsable branch logs neither the address nor the exception, and says why: that exception's message *is* the address, so logging the cause would put back exactly what redacting removes. **The transcript URL no longer carries the token.** Apache logs the request line, so every /api/transcript fetch wrote the server's token into its access log in plaintext, across fourteen rotations. It was redundant there: the phone sends Authorization: Bearer anyway and tokenOK takes either. The audio URL still carries one -- not redundant, the downloader has no credentials of its own -- so removing that needs an app change installed before the server stops emitting it. The gate refused this twice and was right both times. The coverage drop was not all deletion: the redaction and the cancellation contract were genuinely untested, and have tests now. Then detekt caught an import left behind by moving the redaction into FeedFailureText -- a move I made after the last time I ran it. 1031 app tests, 376 server tests, detekt 0, lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
10 daysSay what happened in words, and stop streaming from publishersPeter Stone
Three things, from one red banner on Peter's phone reading "Playback failed: ERROR_CODE_IO_BAD_HTTP_STATUS". His words were "arcane and sketchy". Both halves were fair, and the second was pointing at a real bug rather than a wording problem. **The app was streaming episodes from their publishers.** `toMediaItem` falls back to the enclosure URL when there is no local file, and nothing stopped a play reaching it: press play on an undownloaded episode and it opened the publisher's CDN directly. Three things wrong with that. It is the automatic publisher fallback the download path had removed on purpose in October ("Do not reintroduce an automatic fallback for a transient failure"), surviving where nobody looked. It is an outbound request to a third party that nothing asked for -- the sketchy part, and Peter was right to read it that way. And worst, nothing in playback checks `audioFromServer`, so the breaks the server measured on *its* copy were being applied to a copy with different advertising in different places: skips landing in the wrong spot with no way to tell why. `PlaybackAdvancer.start` now returns NeedsFetch for any episode with no file, which routes through the download path that already honours the server rule. One caller still reaches the fallback -- Android Auto resolves a browse id straight to a media item without the advancer, and a car that silently plays nothing is worse than a car that streams. That needs fetching inside the session callback; until then OutboundRequestsTest pins the fallback to exactly one line. Ruled out first, with evidence rather than theory: the skip server (every request in its access log is a 200), the user agent (both the app's and Media3's get 200 from the enclosure), and my own deploy the day before. **The messages.** Media3's constant is now a sentence (`PlaybackFailureText`), and it was not the only one. The same pass found `Download failed: ${it.message}`, `Could not read that file: permission denied`, and `FeedFetcher` putting the literal word `UnknownHostException` under a podcast's name in the library -- that one reaches two surfaces, because the text is also stored as the subscription's `lastError`. **Two existing tests asserted the machine text came through**, which is how it survived review; they assert the opposite now. Accuracy turned out to be the harder half. "It will be retried" reads better than "Try the download button again" and is a lie: a failed download is deliberately left FAILED. A 404 and a 503 both mean "it didn't work" to a library and are different decisions for the person choosing whether to unsubscribe, so they say different things. Where the cause cannot be narrowed the sentence stays vague rather than guessing. Every cause goes to logcat -- which detekt caught me failing to do in FeedFetcher, three swallowed exceptions after I removed their messages. **The guardrails Peter asked for**, both watched failing before being trusted: - `PlainLanguageTest` -- no listener-facing message may be built out of an exception or spell out an error constant. Deliberately scoped to where errors surface rather than judging all prose. - `OutboundRequestsTest` -- one OkHttpClient, built in AppGraph, carrying SecureSchemeInterceptor; nothing may open a connection another way; and the publisher-stream fallback must stay at one site. The same arrangement the server has in safefetch/audit_test.go and for the same reason: this app fetches URLs chosen by podcast publishers. **And the readout, which Peter asked for directly.** The per-show "Detected skips" lines read the confirmation rows the on-device detector used to write, so since detection moved they could only ever say "Nothing found yet" -- on every show, including ones the server was publishing breaks for. Counted off the episodes now. Verified on a device against a staged state rather than argued: three of five episodes carrying a detected intro, one carrying an ad break, reading "Found in 3 of 5 episodes" / "None found in 5 episodes" / "Found in 1 of 5 episodes". *Not checked* and *checked, found nothing* are different answers again, which was the whole point of the readout. 1016 tests, detekt 0, lint clean. Coverage 94.58% -> 94.12%, COVERAGE_EXCEPTION for the same reason as yesterday: the deleted confirmation-row code was covered. 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 daysWire up the sleep timer's second mode, and make a duration extendPeter Stone
Peter's call on both, after the view-model work surfaced them as dead code. SleepTimer was documented as working "in two modes: a countdown, or stop at the end of the current episode", and armForEndOfEpisode, shouldStopAtEpisodeEnd and extendBy had no caller anywhere. So "stop at the end of this episode" did not exist in the app, and every duration in the sleep dialog went to armFor, which resets -- tapping 15m on a timer with thirty seconds left gave 15:00 and quietly took those thirty seconds away, the one thing extendBy's own comment says not to do. A duration now adds to a running timer and the buttons say so, "+15m" rather than "15m". The dialog offers "End of episode", and the overflow entry reads "Sleep timer - end of episode" in that mode, since there is no countdown to show. The two modes watch differently on purpose. A countdown needs a clock, so it ticks. Stopping at the end of an episode needs the moment the episode is done, and a one-second poll means a second of the next episode playing to somebody who asked to be stopped -- so it follows nowPlaying, which already republishes every second for the progress bar, and treats the queue advancing as the end whatever the last position said. Adding the mode took PlayerViewModel past its function limit, so the whole concern moved to SleepController -- the answer ListenViewModel got when it outgrew one, rather than raising the threshold a third time. SleepTimer keeps the arithmetic and stays pure. ReachabilityTest now covers classes, which is why this was invisible: it scanned `object` declarations only, and SleepTimer is a class. Classes cannot use the object check's matching -- a call names the instance, not the class -- so they get the DAO check's looser `.name(` matching, which needed three call shapes before it was honest: the ordinary call, a `::name` reference, and a trailing-lambda call with no parens at all, which is how OpmlViewModel reaches importFrom. It found one more thing, allowlisted with the reason rather than hidden: **on-device detection is dead code.** FingerprintUpdater and MidrollUpdater are never constructed -- every reference from outside their own files is a KDoc cross-reference -- and through them IntroOutroDetector, AudioCorrelation, MidrollScanner, MidrollDetector, MidrollClipStore, MediaCodecClipSource, Decimate, Fft, MonoResampler, PcmCodec, StoredClip and BreakTrack, about two thousand lines shipped in every APK. The workers went when detection moved to the server; the classes stayed. Deleting a subsystem is Peter's call, so it is recorded in the allowlist instead. Two smaller things found on the way. EpisodeDownloader's wait notification went onto ServerAudio, where the rest of the server's side of a download already lives, rather than becoming a seventh constructor parameter -- and the first attempt added it *after* the `now` clock, which silently rebound every existing trailing-lambda call site from the clock to the new hook while still compiling. The UnusedImports rule enabled earlier today caught a stale import in this very commit. 1057 tests, detekt 0, lint clean, coverage 95.17% -> 95.29%. Checked on the emulator: the dialog offers End of episode, and arming 15m shows "Sleep timer - 14:52" on the overflow entry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
12 daysKeep what the listener asked for, and publish ads only from a publisher's masterPeter Stone
Two changes to the server, both measured against the live data rather than reasoned about. **Subtraction publishes only from a publisher's transcript.** Subtracting a publisher's transcript recovers 94-104% of the advertising the file lengths imply; subtracting a refetch -- a second download of the same assembled file, carrying its own ads in its own places -- recovers a median of 33%. That tier had already been tried and rejected at 21% and 0%, but nothing in the publish path was told which master it had, so it reached the phone anyway on seventeen ad spans. Written as a denylist first, and measuring it against a copy of the live data showed why that was the wrong shape: sixteen episodes carry a master whose label is blank -- the field postdates the first masters written, and Reclaim restored those files after the library was destroyed with the blank intact -- and they published on the strength of not being the one named bad kind. They happened to be publisher transcripts, so the answer was right by luck. It is an allowlist now, and the blank labels are repaired from the master files, which have said "published" all along. Measured on a copy, not predicted: before 49 ad spans (17 refetch, 24 unlabelled, 8 published) + 604 cue, 245 episodes after 62 ad spans (all publisher-master) + 611 cue, 245 episodes No episode loses its skips; the refetch ones trade advertising nobody could measure for the show's own recurring audio, which is a preference rather than an unconditional skip. Measuring also caught the recurring pass blanking the refusal subtraction had just written -- both passes answer "why is nothing published" through one field and recurring runs second, so all thirty-two episodes that now carry a reason were losing it a moment later. **Being asked for keeps an episode mirrored.** Retention was publication date alone, and pruning calls words.Forget, which deletes the transcript *file*. So an episode outside the newest eight was fetched on demand, recognised, served, and deleted on the next poll -- and asking again paid the whole cost over. From the server's own journal over twenty-one days: 397 transcription runs covering 309 distinct episodes, with 7.7 of the 33.4 hours of whisper time spent on episodes it had already recognised and thrown away. 23% of the budget, re-doing work it had discarded. Working through a back catalogue -- the archive tier, the slow-drip shows, the listening this was built for -- therefore paid full recognition for every episode and again to resume one. The window still decides what is worth fetching; RequestedAtUnix now decides what is worth keeping, stamped on every /api/episode call including for episodes already mirrored, because resuming something played last month must not let it fall out mid-listen. Kept for 28 days after the last ask, the same span refetchAfterSeconds uses, being the same kind of judgement about how long something stays interesting. Disk points the same way: transcripts are 36MB for 445 files, audio is 17GB for 336 episodes, and the volume has 365GB free. The expensive artefact is the small one. 374 server tests pass. Not yet deployed; the phone's half of this -- a pending reply is retried only on a feed refresh -- is still to come. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-31Shows get a priority, so the news is not stuck behind a back cataloguePeter Stone
Peter's framing: a few shows are news and stop being worth hearing within a day, a tier he keeps up with, and a long tail that is not topical at all. It matters because recognising one episode costs the server about five minutes, so a five-hour back-catalogue episode arriving the same morning as the news takes most of a cycle before the news is looked at. Three tiers and no more. A number would invite fiddling and would not mean anything; these are the three distinctions that were described. The tier rides beside the subscription list rather than inside it, so a server that knows nothing about tiers reads the list exactly as before and a phone that knows nothing keeps pushing what it always did. Only the shows placed somewhere other than the default are sent -- "ordinary" for sixty shows is the same information as nothing, in sixty times the space. It orders both the poll and the recognition queue. Recognition now sorts on three things in the order they matter: a topical show first, then an episode whose answer checks itself against the length gap, then newest. **A tier only exists for a subscribed feed**, which a test caught: the list and the tiers arrive in one push, and setting the tiers after the list put back a placement for a feed that same push had just removed. A resubscription months later would have inherited it. Unrecognised tiers are ignored at both ends and read as the default, which is the one that starves nothing -- the same rule the label store follows for a verdict it does not know. Schema 20, migrated and checked on a device. The control says what it does not do as well as what it does: nothing about playback changes, and a show placed anywhere still plays, downloads and skips identically. A setting that looks like it changes playback and does not is worse than no setting. The coverage gate caught the push body having no test, which was fair -- it is hand-built JSON going over the wire, where a missing brace is a library that silently stops being mirrored. Its exact output is now pinned, including a URL with a quote in it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-31A break says which detector found it, and the episode screen says soPeter Stone
Three detectors now cut audio out of episodes and the app said nothing about which. The listener judging a skip wants to know what judged it, and they are not equally sure of themselves: one compares waveforms across a batch of episodes, the other reads what was said and checks itself against the declared length. A break both found is marked as both, because agreement by entirely different means is the strongest thing either can offer. "Found by the audio and the words", under the numbers and quieter than them -- it explains them rather than competing with them. Schema 19. Existing rows default to the audio scan because that is where every break in the database came from; the words could not produce one until this week. "Unknown" would have been honest about the column and useless about the rows. A server that says nothing gets the same default, for the same reason. The coverage gate caught the new field's default never being exercised, which was fair: every call site passes it, and the case it exists for is an episode with nothing cut and nothing to account for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-29An episode started from the car resumes where it was leftPeter Stone
Reported as "the play button, everywhere, should resume from the listened position, not start over". Every in-app surface was already correct, checked on a device before anything was changed: the library row resumed a seeded 5:00 and played on from 5:07, the episode screen showed "7m in" and labelled its button Resume, and both go through PlaybackAdvancer, which is where this app decides where an episode starts. So the bug was not in the resume rule. Android Auto does not go through any of that. A Bluetooth button, the system's resumption and the car all reach playback through the session callback, and that path had only onAddMediaItems -- which turns a browse id into something playable and says nothing about where in it to start. The player therefore began at zero. It survived because it is wrong only where nobody developing the app presses play. onSetMediaItems now supplies the position, from PlaybackAdvancer, so the car and the phone cannot drift apart on where an episode starts. **The position is looked up from the items as they arrived, not the resolved ones.** Resolution replaces a browse id with a playable item carrying the audio URI, and the browse id does not survive it -- which is the whole reason onGetItem exists. The first version read the resolved item, got null, and resumed at zero: the same bug wearing a different hat. Found by logging it rather than by reasoning about it, after the test kept failing with the fix in place. Pinned by MediaBrowseTest over the real session protocol, because the entire defect lives in a boundary a direct call would skip. Watched failing with only the production change stashed: expected 420000, was 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-28Read the advertising out of an episode's wordsPeter Stone
A second ad detector, and it finds a class the first structurally cannot. The audio scan compares episodes mirrored within a minute of each other, so what it finds is advertising a CDN stitched into all of them -- validated against the declared-versus-served length gap. A show that inserts nothing has no gap, which by that measure means it carries no advertising, and the host-read sponsor block in the middle of it is invisible. Every published transcript the server can use belongs to exactly such a show, because having no inserted advertising is the condition for using one. The two detectors do not merely fail differently; they look at disjoint sets of episodes. **Density, not phrases.** One marker is a mention. Measured against the 38 transcripts harvested from the live library: a single "brought to you by" is a one-second tag inside play-by-play and skipping it would cut the game, and a guest describing how a sponsor came forward for his yacht event says "sponsor" four times while being the programme -- the same trap the toc=false chapter rule already documents. A real read stacks different *kinds* of marker inside a few seconds: who it is for, where to go, what the offer is, what the terms are. Two kinds and fifteen seconds, the same ad unit the length-gap rule uses. Every threshold comes from a measurement rather than a guess. The 25-second bridge is the 17.7-second gap between "presented by Delta" and "12% off" in the Seahawks Rewind read, with the product description in between. "Slash" needs a domain in front of it because the corpus contains "a children's movie slash book for adults". Across all 38 live transcripts it reports **one** candidate: that Delta block, 41:07 to 41:33, with no programme clipped at either end. That is low recall by design -- these are the shows with the least advertising, because they are the only ones whose transcripts can be used at all. The shows with real ad breaks need speech recognition, which is not built. **Nothing is published as a skip.** The candidates are stored with the words and the lines are dimmed on the transcript screen, so the reader can see what the server thinks is an advertisement and disagree with it. Dimmed rather than hidden, because a wrong call has to stay visible. Nothing in the playback path reads them -- same posture as the signature library's shadow run, and the same reason: a detector acting on its own unchecked first guesses makes skips into the middle of the programme, which is worse than no skips. Schema 18 adds the mark to the cue, defaulting to not: a line nobody judged is not an advertisement. Migrated and checked on a device. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-28The app reads an episode's words, and follows them while they playPeter Stone
The ⋮ menu gains Transcript, which opens the episode's lines with the one being spoken in bold and the list following the playhead. Tapping a line seeks to it, which is the thing anybody actually wants a transcript for -- finding the bit you half-heard. It is also why the server refuses a publisher's transcript for an ad-inserted copy. Tap a line in a transcript of the ad-free master and the audio starts minutes from where the words are. The words arrive with the audio rather than when the screen opens. The point of downloading is that the episode works with no network, and a transcript fetched on opening is blank on a plane or in a car park, which is where this app is used. Tens of kilobytes beside tens of megabytes. One row per cue, not a JSON column. Android reads every Room row through a CursorWindow that refuses a row over 2MB *on the way out* -- the insert succeeds and the next read throws -- which is exactly how the oversized pendingClipPcm crashed the app on every show's second fingerprinted episode. A three-hour episode is thousands of cues, so one column holding them all is that bug with a different name. Both tables cascade from the episode. Schema 17, migrated and checked on a device, including that deleting an episode takes its words. Three states again, kept apart: words to read, a recorded reason there are none -- the server looked at the publisher's transcript and would not use it for this copy -- and nobody having looked. The menu offers the entry for the first two and the screen says something different for each. TranscriptSyncing holds the highlighting, apart from the screen and tested, because the way this goes wrong is quiet: a highlight on the line before or after the one being spoken looks right at a glance and is useless to read along with. It holds the last line through a gap rather than flicking off during silence or music, and highlights nothing before the first line, since an episode opening on theme music has not said anything yet. Verified end to end against a real feed before committing, on a throwaway data directory: Wards Way Podcast mirrored, both episodes' transcripts taken, and the API served 748 cues whose last one ends 466ms before the measured file does. Transcripts live on their own view model rather than two more functions on ListenViewModel, which was already at the size the linter allows -- same call as splitting Screens.kt, and they have a different lifetime anyway. The coverage gate caught the replace transaction having no direct test, which was fair: its clear-then-insert ordering is the thing that would leave a reader looking at an empty list under a header counting lines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-27Write down a refused token instead of only remembering itPeter Stone
401 and 403 are permanent by decision -- a refused token cannot fix itself -- and the notice saying so was held in memory. So it vanished with the process and only came back once the next refresh had gone and been refused all over again, which for 61 feeds is hours of the app quietly downloading from publishers with nothing on screen to say why. It now lives in the preferences file and is there at launch. Saving different credentials clears it at once rather than waiting for a request to prove it, because that is the one act that answers the complaint. Saving the same ones again proves nothing and leaves the notice standing. Behind a small interface so SkipServer does not have to know whether it is writing to a preferences file or to nothing: every test wants it in memory, without a Context to hang a DataStore off. The stored one takes the reading and the writing as two narrow seams rather than a Preferences. Passing Preferences dragged a Context in and made it the one piece of the server client that could only be exercised on a device -- the coverage gate caught that, which is what it is for. What it actually decides, when *not* to write, is now plain Kotlin with five tests on it: clearing runs after every successful reply, so two hundred successes in a sweep must write nothing. Verified on a device: a refusal written by one Preferences instance is read back by another, and four cases around saving credentials behave as described. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-25Library and settings, with less in themPeter Stone
Step 4 of the UI redesign, and the last of it. The library is one list rather than a list plus a card. Up Next is a heading and a row, lining up with the rows under Shows instead of sitting in a bordered box above them. A show row is a dot, a title and a count: forty-three shows is a list to scan, not a shelf to browse, and the one thing worth seeing per row is whether there is anything in it. The count is new -- the query had already counted it to do the ordering and was collapsing it to a boolean on the way out, so the screen said "something waiting" where it could say "four". Settings is three groups ordered by how often they are touched. Skipping first, because it is what is actively being tuned. The build hash drops to the bottom under About, rather than sitting between the automation rules and the subscriptions at equal weight. Two things left Settings. Playback speed was already a tap on the episode screen, so the permanent row of chips was a second way to do one thing. The sleep timer moved to the episode overflow, where every other secondary playback action went in step 3 -- it is not a setting, it is something you do once, while listening, usually in the dark. The entry shows the time remaining, which is the only question anyone has about a timer they set in the dark. The reachability test earned its keep here: dropping the speed section orphaned PlayerViewModel.setSpeed, which it reported as a feature no screen could reach. It was genuinely superseded -- setSpeedFor persists the choice per show and applies it -- so it is deleted rather than given a new home. Verified on the emulator, which is the only way Compose gets verified here: the library renders dots, counts and a dimmed errored feed; the three settings groups read in order; and the sleep timer arms from the overflow and then reports "Sleep timer - 29:51". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-24Send the listener's labels to the server and keep themPeter Stone
The phone has had two kinds of label and nowhere to put them: a mark, which is a judgement on a break detection found, and a report, which is the notification button pointing at advertising it missed. They catch opposite errors, and the signature library needs both. POST /api/labels now takes them and state.json keeps them. Episodes are named by feed and guid rather than by either side's own id -- the phone never sees the server's, and the server's belongs to a mirror that forgets an episode after eight. The labels sit beside the shows rather than on them for the same reason: the corpus has to outlive the audio it was given on. A report's end is sent as guessed or measured. It is only measured once the listener came back and trimmed it; otherwise it is wherever the jump landed, and a signature cut to that would carry programme. The phone re-sends everything each sweep and keeps no record of what arrived. The server replaces rather than accumulating, so a re-send is free, there is no sync state to get wrong, and a label lost to a crash comes back on the next refresh. Gated on audioFromServer, like every other server re-query: a label names a position in a particular file, and dynamic ad insertion means the publisher's copy is a different one. Accepting a label writes the state file immediately. Everything else is written at the end of a poll cycle, which for 61 feeds is hours, and a tap made once should not wait that long to become durable. Deployed and checked against the live server: a label is accepted, a re-send leaves one, an unauthenticated POST gets 401, and the label is on disk within seconds. The probe label used for that check was removed afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-20Jump 25 seconds, the forward button's amount, not 45Peter Stone
The "this is an ad" press jumped 45 seconds, a number argued from ad-unit arithmetic rather than from the controls that already exist. Peter: "the jump should stay 25 seconds. Don't go inventing new timing." Skipping forward is already what you do to an advertisement, so the button goes the same distance and additionally says why. One number to know, and a second press goes the same distance again. The amount had three definitions already -- the player's increments, the episode screen's transport buttons, and the notification caption's hardcoded "Forward 25 seconds" -- and this nearly became a fourth with a different value. They now all read one, and the captions are derived from it so a caption cannot name a number its button does not do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-20Add a "this is an ad" button to the notificationPeter Stone
The notification carried four buttons doing two jobs: Media3's default previous/next (which PodcastPlayer redefines as seek) plus our own back-15/forward-25 pair. Media button preferences with explicit slots let the custom pair claim the two seek slots, so the duplicates go and the freed space carries "this is an ad". No player command is removed to achieve it, which is the route that once silently killed the headphone skip button. One press means four things, told apart by where the playhead lands rather than by guessing: press claims advertising from here and jumps past it, pressing again extends the same claim, coming back inside the jump moves its end to where you landed, and coming back past the press withdraws it. Outside a 20-second window a seek is ordinary navigation and says nothing. The window is stored with the label so changing it later cannot reinterpret old data. Every backwards seek feeds the gesture, whatever caused it -- notification, headphone remote, in-app bar, dragged slider -- because you correct with whatever is to hand. This is the opposite evidence to the tap-a-break popup. That one can only correct breaks detection already found, and detection accounts for about 38% of the advertising the feeds imply. Verified on the emulator: the notification now shows exactly back-15, pause, forward-25 and the new button; the lock screen shows it too and pressing it there records a claim without unlocking; all four gesture arms behave as specified; and the media transport keys still seek by 25s and 15s. Android Auto still needs the car. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-20Let the listener validate or invalidate a detected break from a popupPeter Stone
Keeps partners on episode_midrolls (schema 15), stores verdicts in their own break_verdicts table so a server refresh cannot wipe them, and drops invalidated breaks from skipping on the next tick. Verdicts are local only. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-19Replace the skip readout rows with three numbers and tappable marksPeter Stone
Served / declared / cut on one line, a two-colour legend, and marks on the track that name themselves and seek when tapped. The per-break list and the permanent-None intro/outro rows are gone. Coverage: dropped 94.23 -> 94.15 because covered readout code was deleted. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-17Treat a rejected skip-server token as permanent, not a reason to waitPeter Stone
HTTP 401/403 from the skip server now downloads the publisher's copy at once (audioFromServer=false), as if no server were configured. Unreachable, timeout, 404, 5xx, unparseable and pending still wait. The transport reports Body / Rejected / NoAnswer and makes no decisions. The rejection is remembered (from episode requests and pushSubscriptions) and shown in the skip-server settings until a different token is saved or a request succeeds. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-17Wait for the server's copy instead of silently taking the publisher'sPeter Stone
With a skip server configured, a podcast episode it cannot supply yet (pending, unreachable, bad answer, or audio that fails) now enters WAITING_FOR_SERVER and is retried on every refresh, instead of falling back to the publisher's differently-ad- inserted file. Taking the publisher's copy is an explicit action on the episode screen that sets audioFromServer=false. No server configured, YouTube/Facebook and unresolvable feeds still download directly. No schema change: downloadState is stored by name as TEXT. detekt: EpisodeDao and ListenViewModel function-count thresholds raised by one (rationale in detekt.yml). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-17Refresh skips with bounded concurrency, not one at a timeClaude
The sweep awaited each episode's server query sequentially, and it runs on every refresh including the library button. Several hundred downloaded episodes meant a tap that used to take seconds took minutes, and under WorkManager it risked being killed partway -- the same shape as the ten-minute ceiling that stopped FingerprintWorker ever confirming. Six at a time, through a semaphore inside the call's own coroutineScope so nothing escapes to a background dispatcher. Per-episode failure isolation is unchanged; CancellationException is rethrown rather than caught, the bug already fixed once in skipFurniture(). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-16Refresh skips on feed refresh, without stamping server offsets onto ↵Peter Stone
publisher audio A feed refresh never re-asked the skip server for already-downloaded episodes, so detection improvements only ever reached a show by deleting and redownloading it. Fixing that safely needs to know which episodes are safe to re-query: a show using dynamic ad insertion serves a different file on every request, so a server-measured offset only applies to the server's own copy, not whatever the publisher fallback left on disk. - EpisodeEntity.audioFromServer (schema 13->14, MIGRATION_13_14) records which source actually landed; EpisodeDownloader sets it, defaulting to false everywhere else so a requeue, retention reclaim or failed re-download clears a stale claim the same way they already clear localPath. - New SkipRefresher re-queries downloaded, server-sourced episodes via the existing ServerSkips.resolve/record pair, one episode's failure not blocking the rest. Wired into SubscriptionRepository.refreshAll via a new refreshSkips collaborator -- the choke point every refresh route passes through, including the library's refresh button. - SkipSummary's episode screen readout gets a distinct line for a download that can never be refreshed in place, instead of the misleading "Not checked yet". Migration verified on-device (gradle-verify connectedDebugAndroidTest, MigrationTest: 14/14). Full connected suite: 31/31. Unit suite: 682/682. detekt/lint clean; coverage 93.86% against a 93.85% baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-16Add a toggle to play ad breaks at 3x instead of skipping themPeter Stone
skipShowFurniture still decides which breaks get acted on; the new fastPlayBreaks preference (default off, matching today's behaviour) decides what the action is once a break is already due to be acted on. PlayheadPolicy.decide is restructured into tiers (finish, break, chapter, fallback-speed) chained with ?: so Finish still beats both skipping and fast-play, and to stay under detekt's return-count limit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-14Name each break instead of calling all of it advertisingPeter Stone
111 episodes reported 'Ads inserted' about a show's own opening theme and closing credits.
2026-10-14Skip advertising always and the show's own audio by preferencePeter Stone
An unrecognised kind counts as advertising, so a kind added by a newer server does not quietly stop being skipped by an older app.
2026-10-14Keep the kind the server sentPeter Stone
Defaulted to advertising so a server that predates kinds still parses, and because that is what such a server meant by every break it sent.
2026-10-14Give a stored break its kindPeter Stone
Existing rows become advertising, which is what they behaved as: every row in this table was skipped unconditionally before kinds existed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-08Play episodes whose enclosure is still cleartext httpClaude
Reported from the phone: Show 62 of Hardcore History failed with ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED. Android has refused cleartext since API 28 and this app declares no exemption, so any enclosure published over http fails the moment it is streamed rather than played from a downloaded file. Dan Carlin's older episodes are published that way while the recent ones are not, which is why it went unnoticed; the manifest has not changed since long before the skip server, so this is not a regression from that work. Upgrading the scheme rather than permitting cleartext: that exact URL answers 200 over TLS and a range request returns 206 audio/mpeg, podcast CDNs serve the same path either way, and an exemption would weaken every other request the app makes for the sake of a few old enclosures. A host with genuinely no TLS is no worse off, since cleartext was refused already. Two paths, because they do not share a stack. Everything that goes through OkHttp -- downloads, feeds, chapters, artwork -- is covered by an interceptor on the one client the graph builds, which is the right home for a property of the app's networking rather than of any one feature. ExoPlayer has its own data source, so playback converts the URI as it builds the media item. Putting the conversion at the downloader's call site instead broke four of its MockWebServer tests, which was the tests making a fair point: a global network policy does not belong at one call site. Components under test build their own client and are untouched by the interceptor. Also stops the server starving on-demand episode requests. The poll loop and the request queue share a goroutine, so an episode the phone asked for waited out the whole cycle -- hours across 42 shows, which is why the tap that produced this report registered the episode and never fetched it. Now drained before each feed, bounding the wait at one feed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-06Follow the data: detect the advertising, not the introPeter Stone
The property that broke intro detection is the property that makes this work. Eight episodes mirrored within one minute are maximally dependent, because the ad server inserts whatever campaign is running into all of them identically. For intro detection that destroys the independence the confirmation threshold needs -- which is how it came to confirm an advertisement on 8 of 8 episodes. For ad detection it is the signal. And unlike intro detection it is checkable against a number the detector does not produce: the feed declares the ad-free length, the file served is longer, and the difference is the advertising. **Measured on the live deployment, Radiolab's eight mirrored episodes: the scan accounted for 53:29 of the 59:39 the length gap implies, with every individual episode between 71% and 105%.** That is the first time detection in this project has been checked against external ground truth and agreed with it. The old sliding search could never have done this -- one full-length correlation per window position, 199 seconds for a 240-second clip. BestLags computes the cross-correlation over every lag in a single FFT pair, which says where two recordings align; MatchedRuns then walks the extent of the agreement directly, window by window, with no transform. Sized against measurements rather than guesses: 2^22 is 1.69s, so a 70-minute episode decimated to 250Hz is about five seconds a pair. Silence is explicitly not agreement, or separate advertisements weld together across the pauses between them. Breaks publish as ranges into episode_midrolls, which MidrollSkip and PlayheadPolicy already read -- no playback changes and no migration. Intro and outro offsets are still computed and logged but no longer published, and the ad-derived ones already in the server's state were cleared. Two guards publish nothing rather than something wrong: an episode whose matches cover over 35% of it, and any single match over four minutes. 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 over again. Also three data-loss bugs, found by reading the journal after a restart rather than by any test. The subscription list -- the one thing the server cannot rediscover -- was persisted only when a whole poll cycle finished, which for 61 feeds is hours. A cycle re-applied its stale snapshot at the end, which would undo a push that arrived during it and then 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. All three fixed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-05Move skip detection to a server, and find that it detects adsPeter Stone
The server is Peter's design and it works: listend 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*. Deployed on tungsten behind Apache at https://listen.terst.org. FingerprintWorker and MidrollWorker are deleted -- the battery drain is gone because the work is. Serving the audio is load-bearing. Dynamic ad insertion turned out to be on every episode: 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 offsets land in the same columns the on-device detector wrote, so playback needed no changes and there is no migration. **The headline finding is a failure, and it is proved rather than suspected.** Detection reported the intro confirmed on 8 of 8 Radiolab episodes and passed its self-check. It had locked onto an advertisement. The chosen segment was searched for in an ad-free copy of the same episode fetched hours earlier: r = 0.0621. It is in the ad break, not the programme. The confirmation threshold assumes three *independently fetched* episodes. Mirroring 8 within one minute from the same ad server makes them maximally dependent -- whatever campaign is running is in all of them, identical -- and SelfCheck cannot catch it, because the segments genuinely are the same audio. Moving to a server fixed the cost problem and broke the independence the safety mechanism rests on. The published offsets are head-window matches, so they happen to skip pre-roll advertising, which is useful by accident and will move whenever the campaign rotates. It is not intro detection and is not described as such anywhere. The fix, not made: record when each episode was fetched and require the agreeing episodes to span more than one fetch occasion. Reasoning, and an argument for detecting the ads directly instead, in docs/detection-findings.md. Also recorded there: two tooling traps that each cost real time. `android-ui tap` can land on the soft keyboard, because the dump reports layout coordinates the IME does not move -- a Save button and a text field both appeared tapped and neither fired. And the server's token is alphanumeric now because `adb shell input text` ate the leading "-" of a base64url one and produced a silent 401 that looked like broken wiring. 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-29Protect a show's Tier 3 pending episode from retention reclaimPeter Stone
Peter's call (option 1 of the three in docs/detection-findings.md): exempt an episode retention would otherwise reclaim while it is a show's pending comparison basis, rather than extending the grace period or accepting the gap. RetentionSweeper gains a pendingEpisodeIds seam (suspend () -> Set<String>, defaulting to empty) and filters its reclaim candidates against it before the grace-period policy ever sees them -- every existing caller and test was unaffected by the default. RetentionWorker wires the real thing: the union of two new, narrow DAO queries -- ShowFingerprintDao.getAllPendingEpisodeIds() (intro/outro) and MidrollDao.getAllMidrollPendingEpisodeIds() (midroll, reading SubscriptionEntity.midrollPendingEpisodeId). Applied to both fingerprinting mechanisms, not just the one that got diagnosed: MidrollUpdater's discovery has the identical rolling-pending- episode shape and would race retention the same way for a fast show, just silently, with no frozen row to reveal it. Not yet re-verified on a device -- the next live test is two Up First downloads close enough together to actually compare, confirming pendingEpisodeId advances to a real second episode instead of restarting. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sj3CoWEedM56bebByKgNxA
2026-09-29Start shared timestamps playing, and stop the feed clobbering real lengthsPeter Stone
**The hand-off.** Sharing a timestamped YouTube link now opens Listen on the player, already playing at that point. Two things were missing. The timestamp was only honoured on first insert -- the insert ignores a clashing guid, so every share after the first silently did nothing and playback resumed wherever Listen had last left it, which is exactly the going-back- and-forth case. And nothing started playing; the video landed in the library to be found. Re-sharing now moves the position and unplays the episode, because sharing a moment in something you finished is how you go back to it. A share with no timestamp still just adds it quietly -- interrupting whatever is playing would be rude. **A regression in yesterday's duration fix, found by following Peter's own observation.** He noted the 22:05-vs-29:34 gap was probably content versus ads. It is -- and that has two consequences I had missed. The first is that `EpisodeMatcher.mergedWith` rewrites `durationMs` from the feed on every refresh, correctly, because it is feed metadata. Writing the measured length into that column meant the correction survived until the next refresh and then silently reverted. So there are two columns now: `durationMs` stays the feed's claim, `actualDurationMs` is what the player measured, and `playbackDurationMs` prefers the latter. The second is that the wrong number was never only cosmetic. It sizes the decode buffer a full-episode midroll scan allocates -- truncating the scan by minutes on precisely the shows that have midrolls -- places the outro offset, and drives the finished check, which would mark a 29-minute episode played at 21 and advance the queue. Those all read `playbackDurationMs` now. And the third thing, which is Peter's idea and free: the difference between the two numbers is the advertising in the episode, with no audio analysis at all. `insertedAdMs`, shown on the episode screen, and a check on the midroll detector -- whatever it eventually finds should add up to about that much. Measuring it turned up something worth knowing: the same Grass Apes came back 7:29 longer than declared on one fetch and 5:34 on another. Insertion happens per request, so this is a property of a file, not of an episode. Verified on the emulator. Hand-off: cold start into the player at 12:39 from a stored 12:30, and the warm onNewIntent path at 25:14 from 25:00. The YouTube resolver itself cannot be exercised here -- this host gets a 302 bot page for every watch URL, as CLAUDE.md already records -- so the new code was driven through the intent it receives, with the resolver's own behaviour unchanged. Durations: list showed 22m on a fresh subscribe, 27m after playing, with 1325000 and 1659454 stored separately, and the episode screen reading "Ads inserted ~5:34". 550 unit tests, 12 migration tests on device, detekt and lint clean. Coverage 92.96% -> 93.02%. 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-28Keep bedtime shows out of the car, and midroll clips out of the databasePeter Stone
Peter, on the sixty-second cap from the last commit: "won't be enough for tangle or the daily show at least. Both have theme music around ads at the end at least." He is right, and the cap was the wrong trade -- trimming a clip to fit a database row truncates the very thing being identified, and the music wrapping a break is part of what makes it recognisable. So the clips leave the database. MidrollClipStore writes them as files beside the audio library and the row holds a path, which removes the length limit rather than negotiating with it: three minutes is 2.88MB, and a CursorWindow hands back nothing over 2MB. Migration 10->11 drops existing candidates instead of converting them -- a migration cannot write files, and every stored candidate was written under the sixty-second cap, so it is the wrong length anyway. They are a cache; detection starts again. The intro/outro fingerprints are untouched, being eight seconds and correct. StoredClip.fits now guards the one decoded clip still kept in a row, the intro/outro reference, at the point it is written. Unreachable at the current eight seconds, and there precisely because this table already shipped a clip that was not. Bedtime shows are gone from Android Auto's browse list too, as asked. Driving is the opposite of falling asleep and a car list is scrolled blind at a traffic light -- the same argument that already hides played episodes there. Voice search still reaches them: asking for a show by name is unambiguous about wanting it. Found while doing it: unsubscribing never deleted anything from disk. The dialog has always promised "episodes, downloads and playback positions", and the rows did cascade -- taking with them the only record of where the downloaded audio was, so the files stayed forever with nothing pointing at them. Midroll clips would have joined them. Unsubscriber deletes the files first and the row last, because the other order is exactly how the leak happened, and it drops the row even if a file will not delete: a show stuck in the library is the worse failure of the two. Verified: 533 unit tests, detekt and lint clean, 11 migration tests on the emulator. Coverage 91.84% -> 92.73%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-28Bedtime shows, dimmed caught-up shows, a show menu, and skip visibilityPeter Stone
Four things Peter asked for, plus a crash found while verifying them. - Bedtime shows. A per-show flag (schema 8->9) that takes a show out of the library entirely; the moon icon in the top bar switches to listing only those. Not a category system -- the one grouping worth building is the one with behaviour attached, which is that entering bedtime mode also arms the sleep timer. 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. The mode is deliberately not persisted; a restart is morning. - Shows with nothing waiting are dimmed. Same condition the library sort already used -- nothing unplayed and unqueued -- now returned alongside it as SubscriptionListing.hasWaiting. That comes from a COUNT rather than the sort key on purpose: an episode with no publish date ranks at 0, which is right for ordering and would have greyed out a show with one sitting unheard. LibraryOrderTest pins that case. The lastError line keeps its error colour through the dimming, since a failing feed looks exactly like a feed with nothing new. - Per-show controls moved into an overflow menu and a settings sheet, and the tutorial prose is gone. Reaching the episodes used to mean scrolling past drip cadence and two paragraphs; Peter wrote the requirements and does not need them explained back. Unsubscribe went into the same menu, off the top bar where it sat one tap from Refresh. - Skip detection is visible at last, per show and per episode. SkipSummary is pure and its wording is tested, because the distinction that matters is confirmed-and-acting versus still-gathering, and "not checked yet" is a third state again -- midroll scanning runs nightly, so a fresh download legitimately has nothing to say yet. And the crash. Opening the new readout on a device threw SQLiteBlobTooBigException, which turned out not to be the readout's fault: show_fingerprints.pendingClipPcm held a whole decoded head/tail window -- 240s at 8kHz is 3.84MB as 16-bit PCM -- and Android reads every Room row through a CursorWindow that refuses anything over 2MB *on the way out*. The insert succeeded and the next read threw. Intro/outro detection therefore died the moment a show's second episode was fingerprinted and could never have worked as shipped; nothing off-device could have caught it. Migration 9->10 drops the column and keeps only pendingEpisodeId, re-decoding on demand -- exactly what midrollPendingEpisodeId already did for the same reason. StoredClip now owns the limit, MidrollUpdater caps a candidate segment at 60s before persisting it (a long sponsor block would hit the same wall) and refuses anything that still would not fit, and StoredClipSizeTest pins both against the limit so the class cannot come back quietly. Trimming under-skips a very long break rather than over-skipping into content, which is the safe direction. Verified on the emulator: 10 migration tests including the never-before-run 7->8, LibraryOrderTest, and the whole reported flow by hand -- bedtime split, timer arming and cancelling, the dimmed row, both readouts. Re-ran the exact sequence that crashed: three downloads, no crash, FingerprintWorker SUCCESS, and the MP3 decoder visibly running, which is the first time MediaCodecClipSource has executed on hardware at all. Still open, and recorded in CLAUDE.md rather than glossed: detection has not actually confirmed an intro or outro for a real show. Three Up First downloads left both ends "Nothing found yet". Possibly correct -- the threshold is strict by design -- or possibly the 8-second reference window against a roughly 6-second sting. That wants the desktop prototype, not a guess at a tuned constant. Coverage 91.76% -> 91.84%. 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-26Name a shared video from the extractor when its page will notPeter Stone
Facebook serves no og: tags to a non-browser at all, so a shared video was filed as "Video 10153231379946729". yt-dlp knows perfectly well what it is called, and is going to be run against that URL anyway. The probe only happens when the page gave no title: a title that was shared is the one that was meant, and this costs a network round trip. It is allowed to fail, because it buys a nicer name rather than anything the episode needs -- a video that cannot be probed still downloads and plays. Duration comes along with it, which Facebook episodes previously had to do without entirely. This retires the last entry but one from the reachability allowlist: YouTubeAudio.probe has been sitting there since phase 1 described as "the resolver will want it for episode durations", which turned out to be exactly right. Verified on a device: the same Facebook video that arrived as "Video 10153231379946729" this morning now arrives as "How to Share With Just Friends", 74 seconds. No permission changes, no schema change. 428 unit tests green, detekt and lint clean, coverage 89.93 -> 89.97. 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-26Seed the default filters on a fresh install too, not only on upgradePeter Stone
Caught while verifying on a device: after a reinstall the rules table was empty. A fresh install creates the schema at the current version and never runs a migration, so putting the seeding only in MIGRATION_4_5 gave the trailer and preview filters to upgraders and silently to nobody else. Peter's own phone would have been fine -- it is an upgrade from v4 -- but a reinstall later would have quietly lost them, which is the kind of gap that surfaces as "I thought you set that up". The SQL is now shared between the migration and a RoomDatabase.Callback, and covered by an instrumented test building a fresh database and asserting both filters arrive. No permission changes, no schema change. 425 unit tests green, 11 instrumented, detekt and lint clean. Coverage 65.18 -> 65.04, exception recorded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B