<feed xmlns='http://www.w3.org/2005/Atom'>
<title>feedreader.git/app, branch master</title>
<subtitle>FeeDreader — personal Android RSS reader
</subtitle>
<id>https://git.terst.org/feedreader.git/atom?h=master</id>
<link rel='self' href='https://git.terst.org/feedreader.git/atom?h=master'/>
<link rel='alternate' type='text/html' href='https://git.terst.org/feedreader.git/'/>
<updated>2026-11-13T19:56:19+00:00</updated>
<entry>
<title>Take the giant Google badge out of the reader</title>
<updated>2026-11-13T19:56:19+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-11-13T19:56:19+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/feedreader.git/commit/?id=7374edc0773988ac3de4d0480a1005d5d1c8cd6d'/>
<id>urn:sha1:7374edc0773988ac3de4d0480a1005d5d1c8cd6d</id>
<content type='text'>
"Remove giant Google icon", reported on a West Hawaii Today article. It is the
"Add as a preferred source on Google" badge that sits beside the byline. Giant
because the page caps it at 120px with its own stylesheet and the reader drops
the page's CSS, so a small badge on the site becomes a full-width image above
the first paragraph.

Matched on the link's address rather than the class, and that is forced rather
than preferred: running the vendored Readability.js over the real page shows
the wrapper's preferred_source_badge class does not survive extraction at all,
so a class rule would match nothing. The anchor does survive, and its address
is Google's own and identical for every publisher in the programme -- where the
badge's image filename sits under this newspaper's theme directory and is not.
So the address is both the only signal left and the one that generalises.

A link with words in it is never touched. An article may discuss the programme
and link to it in a sentence, and that sentence is the author's.

Both cleaners now run from one entry point. There are two places a body is
cleaned -- a fresh extraction on the way out, a cached one on the way back in --
and a cached article is cleaned in place rather than discarded, so a cleaner
wired into only the fresh path would leave every already-read article carrying
litter nothing would ever remove. Peter's report says the article was cached,
which is exactly that case.

Pinned against the real thing: the reported article, put through the real
extractor, loses the badge and keeps its text and byline.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Close the class: a caller can no longer forget where the article lives</title>
<updated>2026-11-13T08:44:07+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-11-13T08:44:07+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/feedreader.git/commit/?id=17643797517693664e6d4f5e5911b9855d6c484b'/>
<id>urn:sha1:17643797517693664e6d4f5e5911b9855d6c484b</id>
<content type='text'>
The fix before this made site-relative images zoomable by resolving them
against the article's own address. It left the parameter optional, which leaves
the bug one forgetful call site away from coming back -- and coming back
silently, as every image on a site-relative feed quietly refusing to zoom,
which is exactly how it went unnoticed on Ftrain in the first place.

It has no default now. Null is still a legitimate answer, because an article
with no address genuinely cannot resolve anything, but it has to be given on
purpose. A future reader surface that omits it fails to compile rather than
shipping the old behaviour.

Nothing about what the app does changed: the same 157 tests pass, and the six
test call sites that relied on the default now say baseUrl = null, which is
what they always meant.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Make an image zoomable even when the feed didn't spell out where it lives</title>
<updated>2026-11-13T08:40:44+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-11-13T08:40:44+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/feedreader.git/commit/?id=fc25307e7f258b1043e1367a44a8800d03085c66'/>
<id>urn:sha1:fc25307e7f258b1043e1367a44a8800d03085c66</id>
<content type='text'>
Tapping an image in a Ftrain article did nothing. The zoom affordance was
there, and the rendering path was the ordinary one -- the feed's own HTML, with
reader mode off -- but a tap went nowhere at all.

Tap detection works by wrapping every article image in a link to a scheme
nothing can navigate to, which the WebView then hands to
shouldOverrideUrlLoading. That href has to carry the complete image URL on its
own, so an image whose src was relative got no wrapper and no tap: better an
image that can't be zoomed, the reasoning went, than a link that goes nowhere.
Ftrain's &lt;content:encoded&gt; is entirely site-relative -- every image in it is
src="/art/uploads/..." -- so not one of them was zoomable.

That reasoning had an unexamined premise. The page itself resolves those srcs
perfectly well, against the base URL loadReaderHtml already hands
loadDataWithBaseURL: the article's own link. Resolving them the same way at
HTML-assembly time produces exactly the URL the WebView went and fetched, so
the wrapper can be built after all. Both reader surfaces now pass that base URL
down to the zoom pass. Where there genuinely is no base URL to resolve
against -- nothing else in the app loads reader HTML without one today -- the
old behavior stands and the image stays un-zoomable rather than acquiring a
dead link, and a data: or javascript: src is still refused however it was
reached, since the viewer could never open one.

This is why the report could only have come from a feed with reader mode off.
Reader mode never hits it: Readability rewrites every src to an absolute URL
before the extracted HTML reaches us, which is also why the same images zoom
fine the moment the feed's reader toggle is on.

TDD: site-relative and path-relative resolution, and the whole trip through
wrapReaderHtml, were written first against the unused parameter and went red on
assertion mismatches (3 of 38 in ImageZoomTest), green on the resolution
itself. The data:-src case was written at the same time and passed immediately
-- it pins existing behavior against the new code path rather than having
driven it.

Verified on the emulator against the reported article itself, subscribed live
from ftrain.com/feed.xml with reader mode off: on the previous build, a tap on
the header image left the screen byte-identical; on this one the same tap at
the same point opens the full-screen viewer.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Give YouTube's player a height, so the picture has somewhere to go</title>
<updated>2026-10-23T10:34:01+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-10-23T10:34:01+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/feedreader.git/commit/?id=04408cceea17f3e32b6e88edf4da94b26385b026'/>
<id>urn:sha1:04408cceea17f3e32b6e88edf4da94b26385b026</id>
<content type='text'>
Peter, three builds running: audio plays, YouTube's title, controls and
scrubber thumbnails all draw, and where the picture belongs there is a
blank rectangle.

None of my guesses were right. It is not the embed, not the window, and
not the surface. It is layout. YouTube's player sizes itself with
`body { position: absolute; height: 100% }`, and in this WebView
percentage heights resolve against a containing block of zero height.
Measured in the running app: window.innerHeight correctly reports 214,
while 100vh and every percentage height resolve to 0. The body
collapsed, the player chain under it collapsed, and the video element
was laid out 380x0.

That explains the whole symptom, including the part I kept failing to
account for. The page, the chrome and the stream were all healthy --
the picture simply had nowhere to go, and what showed through the
zero-height player was YouTube's own white &lt;html&gt; background.

So the fix pins the player chain to the pixel height the page does
know and dispatches a resize so YouTube's own code re-measures.
Verified on a device, with the shipped code and nothing injected by
hand: the video element goes 380x0 -&gt; 380x214 and the video plays.

Two things ruled out along the way, recorded so they are not retried:
hardware-accelerating the dialog's window changed nothing, and nor did
moving the player out of a dialog into an in-window overlay. An A/B
against BrowserScreen looked decisive for the window and was not --
that WebView differs in layout too, and layout was the real variable.

This treats the symptom. Why the viewport has no height is below this
app, in WebView.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Name a title-less feed item by its own opening words</title>
<updated>2026-10-22T22:13:32+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-10-22T22:13:32+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/feedreader.git/commit/?id=d4538340fdbe7016a7b3a2eb040c7b133df19efe'/>
<id>urn:sha1:d4538340fdbe7016a7b3a2eb040c7b133df19efe</id>
<content type='text'>
RSS requires an item to carry a title or a description, not both, and
plenty of feeds take the second option. Mastodon's tag feeds are the
case that surfaced it: every one of the 20 items in
mastodon.social/tags/giftlink.rss has a description and no title at
all, so subscribing to one produced a list of blank rows with nothing
to read or tap.

A post says what it is, it just says it in the body, so the item is
now named by its own first sentence -- falling back to its opening
words when the sentence runs long, and to its link when there are no
words at all.

Two details that the live feed taught rather than the fixture:

Inline tags are removed without leaving a gap, and only block
boundaries become a space. Mastodon splits a hashtag as
`#&lt;span&gt;giftlink&lt;/span&gt;` and a URL as `&lt;span class="invisible"&gt;https://
&lt;/span&gt;&lt;span&gt;wapo.st/x&lt;/span&gt;`, so turning every tag into a space tore
both apart -- first pass produced "# HumaneRights # USPolitics" and
"the loophole https:// wapo.".

A full stop only ends a sentence when whitespace or the end of the
text follows it, or the dot in "wapo.st/abc" ends it instead -- which
is how that same title got cut at "https://wapo.".

It also skips an opening fragment shorter than 20 characters: naming
an article "Wow." helps nobody.

Deliberately free of android.text.Html, unlike the parser's existing
entity decoding -- that pulls in the framework, which is why
FeedParser has no unit tests. This part does, 22 of them.

Verified on a device against the live feed: 20 items, 0 blank titles,
URLs and hashtags intact.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Hardware-accelerate the video dialog's own window</title>
<updated>2026-10-22T21:06:44+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-10-22T21:06:44+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/feedreader.git/commit/?id=9bf135585885b043a05f3e74197314bdb4633c67'/>
<id>urn:sha1:9bf135585885b043a05f3e74197314bdb4633c67</id>
<content type='text'>
Peter, on the referrer build: the player "plays and shows controls,
with a white screen instead of video. The scrubber thumbnails pop up,
but the main playback is blank white." His screenshot shows YouTube's
title, gear, play button, timecode and progress bar all drawn
correctly over a blank area.

That narrows it a long way. Everything the page renders -- chrome,
gradients, the storyboard images behind the scrubber -- composites
fine, so the renderer is healthy and the embed is right. The one thing
missing is the only thing that does not come from the page: video is
composited from a separate surface, and that needs the window it sits
in to be hardware accelerated.

The player is a Dialog, which is its own Window. The activity declares
android:hardwareAccelerated, but that does not carry to a Window a
dialog creates for itself, so the flag is now set on it explicitly.

No new logic, so nothing new to unit-test: the URL and referrer this
loads are unchanged and still covered by YouTubeLinksTest. It is a
property of the window the existing WebView lives in.

Unverified here, and this emulator cannot settle it: it has no working
video path at all in a WebView. It hard-crashed the AVD twice on a
plain &lt;video&gt;, and with software decoding forced it survives but
renders a broken-media placeholder rather than a picture. Everything
up to display is confirmed on-device; display is not.

If the picture is still missing, this rules the window out and the
remaining suspect is the surface itself, which means taking the player
out of a floating window entirely.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Play the YouTube embed with a referrer instead of in an iframe</title>
<updated>2026-10-21T23:47:13+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-10-21T23:47:13+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/feedreader.git/commit/?id=4dd59a40c8a8fed3008082da45e29c1c188b7fe8'/>
<id>urn:sha1:4dd59a40c8a8fed3008082da45e29c1c188b7fe8</id>
<content type='text'>
Peter, on the previous build: "the inline player now plays the audio but
the video is a black screen." So Error 153 is gone and the stream is
decoding -- what is left is a display problem.

The iframe was not needed in the first place. A `Referer` header alone
satisfies the embed, which means the player can be the WebView's own
ordinary top-level document again. That drops two constructs the last
build added: a `loadDataWithBaseURL` page, whose origin is synthetic,
and a cross-origin subframe inside it. Both have a history of rendering
video oddly in WebView, and the black screen appeared with them.

Measured on an Android user agent -- no referrer: Error 153; `&amp;origin=`
in the query: Error 153; inside an iframe: plays; with a `Referer`
header: plays. The referrer is a constant on a reserved `.invalid`
domain rather than the article's URL, so YouTube learns which app asked
and nothing about what is being read -- the same reason the nocookie
domain is used.

Confirmed in the app's own WebView: the embed loads with no error,
854x480, readyState 4, playing.

NOT confirmed: that a picture now reaches the screen. This emulator
cannot render video in a WebView at all -- it hard-crashed the AVD
twice, including on a plain &lt;video&gt; in an ordinary non-dialog WebView --
so the one thing that matters here can only be checked on real
hardware.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Fix stray promo headers, site-URL subscribing, and YouTube playback</title>
<updated>2026-10-21T19:07:17+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-10-21T19:07:17+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/feedreader.git/commit/?id=9210bf463a8818595f7ca42486a787d4f1941d55'/>
<id>urn:sha1:9210bf463a8818595f7ca42486a787d4f1941d55</id>
<content type='text'>
Three reported bugs.

**"West Hawaii today articles get a bunch of stray Trending headers."**
The publisher's widget platform injects promo blocks between the real
paragraphs, inside the article's own container, so every structural
signal tells Readability they belong. What survives is a heading
labelling nothing, because the links under it are filled in by a script
that never runs in the reader. Confirmed on 9 of the 12 articles in
that feed, two each. Blocks marked `data-mrf-recirculation` are now
removed -- the attribute says what the block is for in the widget
platform's own words, which is a better signal than the word
"Trending", since an article may legitimately discuss what is trending.
Nothing longer than a label and a few headlines is ever removed. Also
applied to already-cached articles, in place rather than by discarding
them: the article was fine, only the litter has to go.

**"Can't subscribe to heathercoxrichardson.substack.com."** Adding a
feed handed whatever URL it was given straight to the XML parser, so a
site's home page -- the only URL a reader can actually see and copy --
failed on the root element. That page advertises its feed the way sites
have since 2002, in a &lt;link rel="alternate"&gt; in the head. It is now
read, resolved against the page, and fetched. Only what the page
declares: guessing /feed and /rss would add a burst of 404s to every
failed add and eventually subscribe somebody to the wrong thing.

**"YouTube play is misconfigured."** It was, and YouTube said so: the
player dialog loaded the embed as its own top-level document, which
answers with "Video player configuration error -- Error 153". An embed
has to be embedded. It now sits in an iframe on a minimal host page.
Measured in the app's own WebView: top-level, no requests for the video
stream and the error on screen; framed, the stream loads and there is
no error.

**"Long click on YouTube placeholder should open the URL directly."**
It does now. Worth recording why this is not simply the link's href:
for an image inside a link, WebView's hit test reports the image's URL,
so a long-press on the thumbnail arrives as the i.ytimg.com JPEG. The
video id is resolved out of that.

Verified on a device for all four: the Substack site URL adds "Letters
from an American" and fetches 20 articles; a West Hawaii Today article
extracts with no Trending headers and its body intact; the video plays;
and a long-press reaches the YouTube app.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
<entry>
<title>Render an article's maths instead of showing its TeX source</title>
<updated>2026-10-16T21:04:38+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-10-16T21:04:38+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/feedreader.git/commit/?id=e50f4fbbca21b85648f6c4045cbd50f672d315cd'/>
<id>urn:sha1:e50f4fbbca21b85648f6c4045cbd50f672d315cd</id>
<content type='text'>
Reported against lcamtuf's Substack ("we should render tex",
blog.coredump.cx/p/yours-truly): reader mode showed raw
"\(p = \text{...}\)" where equations should be.

What the page actually ships is the TeX SOURCE, as text, inside a
"latex-rendered" div -- Substack renders it in the browser with its own
script, which never runs here. Readability keeps that text verbatim (it
strips the class, not the text), so nothing was being discarded during
extraction and no amount of extraction-side repair would have helped:
there was simply never any rendered markup to keep.

So render it during extraction, where JavaScript is already running for
Readability's sake. KaTeX (vendored in assets, never fetched -- this
runs against untrusted third-party pages) converts each expression to
MathML before Readability sees the document, and the reader WebView
renders that natively. The reader keeps JavaScript OFF, as it must, and
needs no stylesheet, webfont or script of KaTeX's; the only reader-side
change is containing a wide equation so it scrolls within its own box
instead of dragging the whole article sideways.

Two details worth knowing:

- KaTeX refuses {align} outside display mode, and Substack wraps display
  maths in INLINE delimiters (a block-styled span does the centring), so
  a mode that renders nothing is retried in the other one. An expression
  neither mode can render is left as source -- no worse than before.
- The reported article came with "extractedContent cached: true", so the
  raw source was already on disk and every open read it back. Recognised
  and dropped on read (same mechanism as the cached consent notice),
  which re-extracts the article the next time it is opened.

APK grows by ~90KB: the vendored katex.min.js (267KB) compresses to 91KB
in the APK. No new permissions, no schema change, no new JS-enabled
surface.

Verified on the emulator against the live article: stale raw-TeX cache
discarded, article re-extracted, all four equations rendered, and a wide
align block scrolls inside itself.
</content>
</entry>
<entry>
<title>Stop reader mode passing a site's cookie notice off as the article</title>
<updated>2026-10-12T00:41:24+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-10-12T00:41:24+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/feedreader.git/commit/?id=3f988c1749460528c31dfc239a3a9544a8f52c30'/>
<id>urn:sha1:3f988c1749460528c31dfc239a3a9544a8f52c30</id>
<content type='text'>
Reported live: reader mode on a New Republic feature showed nothing but
the cookie disclaimer, and had cached it.

Reproduced outside the app by running ReaderExtractor's own injected
script (Readability.js and all) against the real page. With the article
body present, extraction is perfect. With the body absent from the DOM at
the moment the script runs, Readability's first pass -- which discards
"unlikely candidates", and the Didomi banner's class is
didomi-notice-banner -- comes back under its own 500-character threshold,
so it retries with that stripping switched off. The banner's single
paragraph is 510 characters, i.e. just over the threshold, so it comes
back as the article with the right title attached.

Nothing on the Kotlin side looked at that. Any non-blank content counted
as ExtractionResult.Success, was written to articles.extractedContent,
and was never reconsidered -- so one bad load became a permanent cookie
disclaimer, which is the "extractedContent cached: true" in the report.

ConsentNotice recognises such a result: a hard plain-text length cap
first (nothing long is ever rejected, however much consent boilerplate it
quotes), then either a known CMP container id -- Readability strips
classes but keeps ids -- or two or more distinct consent phrases.

The accept/retry/give-up decision moves out of the WebView callback into
decideEvalOutcome so it can be tested without a device. A consent notice
is now "not ready yet", which routes it into the retry the extractor
already does for an empty result; if it survives all six attempts that is
what the page serves, and it fails as ExtractionFailure.ConsentInterstitial
rather than being accepted.

A fixed extractor cannot repair what is already on disk, so
loadCachedExtraction drops a cached notice on read and re-extracts, in
both ReaderScreen and LinkReaderScreen. No schema change: the repair
happens when the article is next opened. Reader mode is still never
turned on heuristically -- replacing a discarded notice does not flip it.

The bug report now also carries the last extraction error, without which
a report about bad reader content cannot say whether extraction failed.

Tests: 59 -&gt; 78.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
</content>
</entry>
</feed>
