<feed xmlns='http://www.w3.org/2005/Atom'>
<title>feedreader.git/app/src/test/java/org/terst/feedreader/data, 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-10-22T22:13:32+00:00</updated>
<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>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>
<entry>
<title>Reflow reader text live while pinching, and let a zoomed image pan</title>
<updated>2026-09-29T07:12:48+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-09-29T07:12:48+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/feedreader.git/commit/?id=a492e2def3dabe5e8de5ef4486b92859850503db'/>
<id>urn:sha1:a492e2def3dabe5e8de5ef4486b92859850503db</id>
<content type='text'>
Pinching in the reader used to scale the whole WebView like a photo and snap
back to the real font size on release. It now drives WebSettings.textZoom as
the fingers move, so the text itself resizes and reflows during the gesture
with nothing to snap back from; images and margins stay put. The reload at the
new saved size is what returns textZoom to 100%, so the two never overlap --
and loading reader HTML now goes through ReaderWebView.loadReaderHtml, which
does both together, so a future load site can't forget the reset.

Reader view for a tapped link now honors the saved font size and the same
pinch gesture, instead of always rendering at the 18px default.

A zoomed image can be panned again. The viewer computed its pan bounds from
values captured when the dialog first composed -- an empty viewport around a
not-yet-decoded image -- because a pointerInput(Unit) block keeps the lambda it
was created with. Those bounds allowed exactly one offset, 0, so zooming worked
and dragging did nothing. The whole pinch/pan step is now one pure function the
gesture callback calls with values it reads fresh each time.
</content>
</entry>
<entry>
<title>Support pinch-to-zoom for reader text size in the article view</title>
<updated>2026-09-15T23:12:44+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-09-15T23:12:44+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/feedreader.git/commit/?id=b5a96ad1d0a412c21159d6265e451f7c3aefc796'/>
<id>urn:sha1:b5a96ad1d0a412c21159d6265e451f7c3aefc796</id>
<content type='text'>
Pinching in ReaderScreen's article WebView now adjusts the same
persisted reader font size the Settings slider controls, instead of
doing nothing (WebView's own page zoom was never enabled, so a pinch
previously had no effect at all).

A ScaleGestureDetector is fed through setOnTouchListener, which always
returns false so it never consumes the touch -- WebView's existing
one-finger scrolling, link taps and long-press-link handling are
unaffected, since ScaleGestureDetector only fires once a second pointer
is down. During the gesture the WebView is scaled live via View.scaleX/
scaleY for immediate visual feedback; at gesture end the scale resets
to 1x and the computed, clamped font size is persisted, which drives
ReaderScreen's existing content-reload-on-fontSizePx-change path to
re-render the actual HTML at the new size.

TDD: pinchZoomFontSizePx (the pure scale-factor -&gt; clamped-px
calculation) was written test-first, red on a TODO() stub, green on the
real implementation -- covered by SettingsTest.

newReaderWebView grew from 6 to 8 parameters to carry the new gesture
hooks, which pushed it over detekt's LongParameterList threshold;
bundled all of its callbacks into a ReaderWebViewCallbacks data class
rather than baselining past it, updating both call sites (ReaderScreen,
LinkReaderScreen). ReaderContent gained no new parameter -- it now
independently remembers its own Settings instance to persist the
pinch-committed size, matching how it already remembers its own db and
scope rather than threading them from ReaderScreen.

LinkReaderScreen's WebView is untouched: it doesn't read the reader
font size setting at all today, so wiring pinch-zoom there would be a
separate, unrelated fix.
</content>
</entry>
<entry>
<title>Let filter rules be scoped to one feed; fix coverage-gate report path bug</title>
<updated>2026-08-24T07:58:47+00:00</updated>
<author>
<name>Peter Stone</name>
<email>thepeterstone@gmail.com</email>
</author>
<published>2026-08-24T07:58:47+00:00</published>
<link rel='alternate' type='text/html' href='https://git.terst.org/feedreader.git/commit/?id=209da9e628112a5a25ac4519370cfdc072462547'/>
<id>urn:sha1:209da9e628112a5a25ac4519370cfdc072462547</id>
<content type='text'>
The data model and FilterRuleEngine already supported a per-feed scope
(FilterRuleEntity.feedId), but Settings' "Add rule" form never let a
user set it -- only a hardcoded migration had ever created one. Add a
FeedScopePicker so a rule can target "All feeds" (unchanged default)
or one specific feed, and show each existing rule's scope in the list.

Added the first real unit tests in this repo (FilterRuleEngineTest),
covering global vs. feed-scoped matching and mute-wins-over-highlight
priority -- the exact behavior this UI gap left unexercised outside
one migration-seeded row. First real jacoco coverage number: 2.31%.

While verifying, found the pre-commit hook's coverage step was
silently a no-op: it pointed at app/build/reports/jacoco/report.xml,
but the jacoco setup that landed here uses the standard Gradle
JacocoReport task, which writes to
reports/jacoco/jacocoTestReport/jacocoTestReport.xml instead. Fixed
the path so the gate actually reads real numbers now.

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