summaryrefslogtreecommitdiff
path: root/app/src/main/java/org/terst/feedreader/data
AgeCommit message (Collapse)Author
26 hoursTake the giant Google badge out of the readerHEADmasterPeter Stone
"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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-22Name a title-less feed item by its own opening wordsPeter Stone
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 `#<span>giftlink</span>` and a URL as `<span class="invisible">https:// </span><span>wapo.st/x</span>`, 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-21Fix stray promo headers, site-URL subscribing, and YouTube playbackPeter Stone
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 <link rel="alternate"> 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-10-16Render an article's maths instead of showing its TeX sourcePeter Stone
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.
2026-10-12Stop reader mode passing a site's cookie notice off as the articlePeter Stone
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 -> 78. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-09-29Reflow reader text live while pinching, and let a zoomed image panPeter Stone
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.
2026-09-15Support pinch-to-zoom for reader text size in the article viewPeter Stone
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 -> 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.
2026-08-31Add reader font size setting, rewrite paywall-avoidance settings copyPeter Stone
Font size was hardcoded at 18px in ReaderWebView's injected CSS. Added a DataStore-backed setting (Settings.readerFontSizePx, 14-28px range) with a slider in Settings, threaded through ReaderScreen/ReaderContent into wrapReaderHtml. The reload-tracking key in ReaderContent's AndroidView now includes font size alongside content, so changing it and returning to an already-open article picks up the new size without reloading on every unrelated recomposition (matches the existing comment there about avoiding scroll-position jank). Rewrote the Settings screen's paywall note -- it read as defensive throat-clearing about what the app won't do. Restated as what it's actually for: avoiding tracking, manipulation, and junk fees, with reader mode pulling up anything findable by search anyway. Regenerated the detekt baseline: ReaderContent's pre-existing LongMethod/LongParameterList/CyclomaticComplexMethod findings are keyed by exact function signature text, and adding the fontSizePx parameter changed that text, invalidating the old baseline entries. The underlying debt (a 300+ line, 9-now-10-parameter composable) predates this change and isn't what this commit is about. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
2026-08-24Remove 3 confirmed-dead DAO methodsPeter Stone
ArticleDao.delete(article) -- superseded by deleteUnsavedByFeed. ArticleDao.markAllReadForFeed -- no "mark all read for this feed" UI action exists; superseded by the id-list-based markReadBatchChunked. FilterRuleDao.getAll() -- every caller uses the Flow-returning observeAll() instead. Re-verified all three have zero callers beyond their own declaration before removing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017GMEkHeqKz6FLkmizowBTK
2026-08-22Mute emptywheel's recurring "Fridays with Nicole Sandler" seriesPeter Stone
Reported via the in-app bug report against a specific numbered installment (-111). Seeds a MUTE filter rule scoped to the emptywheel feed rather than requiring it be added by hand in Settings, matching this file's existing pattern of shipping known personal-data fixes as migrations.
2026-08-22Keep a just-read article visible (and Previous-reachable) until the next fetchPeter Stone
The Unread/Older views are live Room Flow queries filtered on isRead = 0, so marking an article read (which only happens on Next/Previous, not on open) makes it vanish from the list the instant Room re-emits -- mid-scroll, or from underneath the reader's own Previous button, which re-queries this same flow to find its neighbors. Concretely: Next from C to D marks C read and prunes it from the live list; Previous from D then can't find C anymore, and jumps past it to whatever was unread before C. A misclick or the in-flight prune could cost you the article you meant to keep reading. Adds ArticleEntity.readAt (set whenever isRead flips true, cleared on explicit "mark unread"); observeUnread/observeOlderUnread now match "isRead = 0 OR readAt >= sinceFetch", where sinceFetch is Settings.lastFetchAt -- the same durable signal the new-articles snackbar already uses. A read article now lingers (shown dimmed via the existing showReadState styling) until the next real fetch actually completes, not the next DB write, and Previous/Next read from the same still-inclusive query so stepping back to what you just read finds it right where it was.
2026-08-22Encrypt the Stash API key at restPeter Stone
The key is a bearer credential for the user's own Stash server, sent as X-API-Key on every request -- previously stored in plain DataStore preferences, recoverable in cleartext from a rooted device, an unlocked-bootloader device, or a local backup/extraction tool. Now stored via EncryptedSharedPreferences (Android Keystore-backed AES-256). An existing key is migrated from the old plaintext location once, on first read, then the plaintext copy is cleared.
2026-08-22Concurrent feed fetching with durable fetch-result trackingPeter Stone
Feeds are fetched with bounded concurrency (semaphore of 8) instead of strictly sequentially -- with 100+ subscriptions, one slow/stalling server no longer delays every feed queued behind it. A process-local mutex prevents the periodic and manual/alarm-triggered one-time fetches (two different WorkManager unique-work names, so its own dedup doesn't cover this) from ever running concurrently and racing writes to the same feed rows. Settings.recordFetchResult persists lastFetchAt/lastFetchNewArticles on every run, not derived from WorkInfo.State -- a PeriodicWorkRequest cycles back to ENQUEUED after every successful run and never reaches SUCCEEDED, so anything watching for that only ever saw manual fetches. ArticleListScreen surfaces this as a "N new articles" snackbar (acknowledged durably, so returning to the screen doesn't re-show it) and computes live "Checking feed X of Y" progress from the worker's tagged WorkInfo, scoped to the Unread/Older views that actually aggregate every subscription.
2026-08-22v9: batch of feed URL fixes from July 31 triage; dedupe rename-and-reassignPeter Stone
Live triage pass over every subscription showing "Last fetch failed." Host-substring LIKE matching, not exact-string like v6-v8, since the exact previously-stored URL for most of these wasn't known ahead of time. Also extracts the repeated "rename a feed's id, reassign its articles, don't let a failure abort the migration transaction" dance (duplicated inline in v6/v7) into one renameFeedSafely helper used by both those and the new v9 migration.
2026-08-22Feed writes: bulk OPML upsert, targeted fetch-result update, shared togglePeter Stone
- AddFeedScreen: OPML import no longer crashes the app on a non-XML/corrupt file (try/catch around the whole import). Bulk upsertAll() instead of a per-feed getById()+upsert() loop -- 2N sequential DB round-trips for N feeds otherwise, real cost at 100+ feed subscription lists. - FeedDao.updateFetchResult only writes the columns a fetch actually produces, instead of a full-row @Update built from a feed snapshot read before a (possibly slow) network round-trip -- the old path could silently revert a readerModeEnabled/folder edit made via FeedListScreen while that feed's fetch was still in flight. - setReaderModeEnabled(feed, enabled) persists the toggle and returns the exact confirmation text, shared by FeedListScreen's per-row toggle and ReaderScreen's toggleFeedReaderMode -- previously two independently-typed copies of the same snackbar message.
2026-08-22In-app YouTube playback, shared ConfirmDialog, hardened link handlingPeter Stone
- ReaderWebView/LinkReaderScreen/ReaderScreen: tapping a YouTube link inside an article now opens an in-app player (youtube-nocookie.com, JS scoped to just that dialog) instead of handing off to the YouTube app/browser. Falls back to external-open when a confident video ID can't be extracted (playlist/channel links). - A subframe (ad/embed iframe) auto-navigating is now blocked outright instead of being treated like a real link tap -- it fired the exact same callback as a user tap, with no interaction required. - wrapReaderHtml can now show a fallback hero image + date when the feed's own body HTML has no image of its own. - ConfirmDialog: the generic "confirm before doing something irreversible" dialog shape was independently rebuilt from scratch at FeedListScreen's unsubscribe and ArticleListScreen's mark-read-from- here confirmations. Both now share one composable; the latter also chunks its batch update (markReadBatchChunked) so a long-unread feed can't exceed SQLite's per-statement bound-variable limit.
2026-08-22Pre-compile filter rules instead of re-lowercasing per article per emissionPeter Stone
ArticleListScreen calls evaluate() once per visible article against the same rule set, re-run on essentially any read-state change to any article in the list. With a couple hundred articles and a handful of rules, calling pattern.lowercase() inside the old per-article evaluate() was thousands of redundant String allocations of the same small set of strings.
2026-08-22Extract shared saveArticleToStash helper (dedupe 5 copies)Peter Stone
The build-a-StashClient-and-branch-on-the-result dance was independently copy-pasted at 5 call sites (ShareReceiverActivity, ReaderScreen x2, LinkReaderScreen x2). StashSaveOutcome carries the exact snackbar text plus the artifact id, as a sealed class so a caller that cares about the Saved/AlreadySaved distinction can still pattern-match instead of parsing the message back apart. Not yet adopted at any call site -- follow-up commits switch each one over.
2026-08-22Fix Atom feeds crashing the whole parse on xhtml content/summaryPeter Stone
<content type="xhtml">/<summary type="xhtml"> wrap real nested markup, which XmlPullParser's nextText() can't handle (it requires a text-only element) -- it threw and aborted parsing of the entire feed over one xhtml-typed field. Walks and reconstructs the inner markup manually instead, tracking depth against the parser's own getDepth().
2026-08-22Add a cancellable OkHttp Call.await() extensionPeter Stone
execute() blocks the calling thread and ignores the coroutine it runs in -- if that coroutine is cancelled (a screen torn down mid-save, a WorkManager-stopped fetch), the network call keeps running to completion regardless, tying up a thread/socket for nothing. enqueue() plus invokeOnCancellation actually aborts the in-flight HTTP call.
2026-07-25Migrate blog.securitymouse.com to its Blogger canonical https URLPeter Stone
Its own custom-domain TLS is broken, but it's a Blogger blog under the hood (confirmed via its feed's Blogger-format Atom XML and "Server: GSE" on the homepage) -- Blogger blogs are always reachable at a stable, always-https URL keyed by blog ID regardless of the custom domain's own DNS/TLS setup. Checked whether the other two remaining http feeds from the last migration have a similar way out: they don't. webpolicy.org's feed is dead (last post 2015). bit-player.org was rebuilt on a static generator that no longer exposes a feed at any checked path (/feed, /feed/, /feed.xml, /rss, /atom.xml, or a <link rel="alternate"> on its homepage). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
2026-07-25Migrate http:// feeds to https:// where it actually works; add feeds as ↵Peter Stone
https-only going forward Android blocks cleartext by default (targetSdk 28+), so every http:// feed was failing every single fetch ("CLEARTEXT communication ... not permitted"). Checked each of this user's 9 http feeds live via curl: - 6 work fine over https (5 are a plain scheme swap; divingforpearlsblog.com's own https endpoint redirects through an insecure feedburner hop, so that one points straight at feedburner's https endpoint instead) -- migrated via a new Room migration (v6->v7), rewriting both the feed row and its articles' feedId so read history isn't orphaned. Confirmed live: all 6 now fetch clean with no lastError. - 3 have no usable https at all (bit-player.org: connection refused, no TLS listener; blog.securitymouse.com: broken TLS handshake; webpolicy.org: a certificate that doesn't match the hostname) and are deliberately left alone. Going forward, adding a feed (manually or via OPML import) upgrades a given http:// URL to https:// before using it -- most sites serve identical content either way, and a site with no real https support just fails its very next fetch attempt the normal way instead of silently subscribing to a feed that can never actually update. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
2026-07-25Text-only bottom bar, re-extract action, fix YouTube thumbnails, verify ↵Peter Stone
Stash creds before saving - Bottom bar: Previous/Keep unread/Next are now plain text labels, no icons - Add "Re-download and re-extract" to the overflow menu (ReaderScreen and LinkReaderScreen) to force a fresh fetch + Readability pass on demand, overwriting whatever's cached - Fix a real compile bug in the previous commit's reader-mode-off handler (passed null into a non-null setExtractedContent) via a proper clearExtractedContent() DAO query - YouTube embeds: fixed the broken-image-glyph bug two ways -- (1) an 11-char video-ID regex was accidentally matching a playlist embed's literal "videoseries" placeholder path, building a thumbnail URL for a video that doesn't exist, and (2) Readability's link-density cleanup was discarding the replacement entirely (a bare <a><img></a> reads as an ad/share unit). Restructured as a <figure>/<figcaption> pair -- the same shape a normal captioned photo takes -- confirmed live this survives extraction intact - Stash settings: API key field is now a password field with a show/hide toggle, and "Verify and save" makes a real authenticated request before persisting anything, showing why it failed instead of silently saving bad credentials Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
2026-07-24Collapsible folders, long-press link menu, YouTube thumbnails, strip more ↵Peter Stone
Ars sequel chrome - Feed list: folders now collapse (start collapsed, with a per-folder unread count so collapsing doesn't hide "is there anything new") - Long-pressing a link inside an article shows an action menu (open in browser, share, save to Stash, save for later) instead of WebView's default text-selection popup -- confirmed live end to end, including the Stash/save-for-later paths creating a real standalone article - New shared ensureStandaloneArticle() helper (data/ArticleSaving.kt), factored out of ShareReceiverActivity, now also used by the long-press menu - YouTube iframe embeds become tap-to-open thumbnails (JS is off in the reader WebView, so the real player never worked anyway); tapping one goes straight to the YouTube app/browser instead of our own article-extraction pipeline - Fixed back navigation through a chain of links-within-links: each hop was stacking a new backstack entry, so back stepped through the chain one link at a time instead of returning straight to the source article. Confirmed live: A -> link -> B -> link -> C, one back-press now goes straight to A - Extended the <header> chrome-stripping from last round: also strip <footer>, an author bio card, and a "Most Read" trending-stories widget -- confirmed live one of the latter showed up as a giant unrelated photo plus an empty numbered list (its lazy-loaded items never populate in this hidden, off-screen extraction WebView) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
2026-07-23Rebrand to FeeDreader; overflow-menu cleanup; deferred read-marking; ↵Peter Stone
folder-organized feed list - Rename app to FeeDreader with an owl launcher icon and a paywall-avoidance note in Settings - Move Open original/Save to Stash into the reader's overflow menu; add Mark unread there too - Only mark an article read when navigating away via Previous/Next, not on open or on back - Saved list no longer crosses out read items; its count includes them - Back from Unread (the root screen) now opens the feed list instead of exiting the app - Feed list: clearer separator above the per-feed list, folder grouping (from OPML categories) with case-insensitive sort, and a virtual "Last fetch failed" section at the top - Reader extraction: cap unsized decorative SVG icons (fixes Ars Technica's giant section-badge icon), force native loading="lazy" images to load eagerly in the hidden extraction WebView, and drop a lightbox's duplicate caption only when a real figcaption sibling exists - Backlog notes on swipe-to-next research, extraction test coverage, and an async-rendered-gallery race confirmed live on Ars Technica Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
2026-07-23Fix crash on blank-guid articles; simplify browser back; backlog notesPeter Stone
- FeedParser: a feed with a literal empty <guid>/<id> element (confirmed live: a Mother Jones entry) produced an ArticleEntity with id = "" -- the fallback chain only checked for null, not blank, so an empty-but- present tag passed straight through. An empty-string primary key broke Navigation Compose's route matching and crashed on tap ("tapping this article crashes the app"). Fixed both RSS and Atom paths to treat blank the same as null before falling back to a link-based id. Schema v5 migration deletes any row already on-device with the bad id; it'll come back with a real, hash-based id on the next fetch of that feed. Verified live: zero blank-id rows after the migration and a fresh fetch, no crash. - BrowserScreen: back (both the system gesture and the in-app arrow) now always closes the screen directly, rather than stepping through in-page WebView history first. Reported live as not making sense for multi-page browsing (search results -> article -> another link, etc.) -- you'd have to tap back that many times just to leave. There's no separate in-page-back affordance now, matching how every other screen in this app behaves. - Tried moving swipe-to-next-article detection off Compose's gesture system entirely (a passive native OnTouchListener on the WebView, never consuming) on the theory that Compose's gesture arbitration was the source of reported scroll jerkiness. Reverted after confirming live that swipe stopped working at all with that approach -- a worse regression than unproven jerkiness. Back to the known-working Compose-based detector; the jerkiness complaint itself is still open. - BACKLOG.md: added bug-report-button, smaller-title-text, and bottom-action-strip-with-auto-hide notes.
2026-07-22Remove reader-mode heuristics entirely; explicit per-feed opt-in onlyPeter Stone
Reader mode was still switching kottke articles even after the v3 opt-out and the "un-hide only if thin" fallback -- both were still heuristic (content-length-based), and kottke's posts are routinely short by design, not paywalled, so the heuristics kept firing on ordinary content. Removed the auto-extract-on-thin-content logic entirely: reader mode now NEVER turns on by itself, under any condition. - FeedEntity.readerModeDisabled -> readerModeEnabled (opt-in, default off for every feed -- schema v4, full table rebuild since Room's migration validation requires an exact column match and a bare ADD COLUMN left the old column orphaned; reproduced live as a crash, "Migration didn't properly handle: feeds"). - The reader's toggle icon now sets reader mode for the article's WHOLE FEED, persists it immediately, and confirms via snackbar ("Reader mode enabled/disabled for X") -- both in the reader screen and the Feeds list's own toggle. This is the only way reader mode ever turns on. - ReaderExtractor: replaced the "un-hide everything if thin" paywall fallback (which is what pulled kottke's site footer into the result -- confirmed live, twice) with one that only un-hides elements whose class/id actually names them as paywall/premium/subscriber content (West Hawaii Today's hidden div is literally class="paywall paywall_<id>"). This turned out not to be the kottke bug's actual cause either -- see below -- but it's a real, independent correctness improvement over the previous blanket version, so it stays. - Also added: noscript-wrapped lazy-load image fallback (a common pattern pairs a placeholder <img> with a real one inside <noscript>, which our JS-enabled extraction WebView never parses into DOM per the HTML spec) and lazy data-src/srcset resolution generally. Root cause of the kottke footer bug, found by fetching and inspecting the actual page structure rather than guessing again: it's not a CSS-hidden-element issue at all. kottke's real post content lives in a plain, undecorated <div class="post"> that's a sibling (not ancestor) of an ads container and a "Socials & More" footer widget, all under one shared <div class="grid-container">. Because kottke's posts are so short, Readability's generic paragraph-density scoring doesn't find enough signal in .post alone and climbs to a broader container that happens to include the neighboring footer. No un-hide logic is involved at any point -- this reproduces identically on the plain, unmodified Readability pass. This is a genuine limit of heuristic content extraction on very short-form posts, not something to keep patching with more heuristics. Reset kottke's setting back to off (its correct default) after testing.
2026-07-22Fix extraction regression on kottke; add Older view, mark-read-from-herePeter Stone
- ReaderExtractor: the un-hide-before-extracting fix (for soft paywalls like West Hawaii Today) was unconditional -- it also un-hid normal, legitimately-collapsed chrome (kottke's footer/membership notice), which Readability then folded into the result alongside the real article. Reported live with a screenshot showing the extracted content wrapped in site footer boilerplate. Now tries the normal (hidden-respecting) pass first and only falls back to the un-hide pass if that came back thin (<500 chars), keeping whichever pass actually found more text. Verified live: the same kottke article now stays clean and stable (no delayed switch to the footer version). - New Unread/Older split: the default Unread view is now clamped to the last 72 hours (RECENT_WINDOW_MS); the rest of the unread backlog moved to a new "Older" view (new FeedListScreen row, ArticleDao.observeUnread now takes a cutoff, observeOlderUnread/observeUnreadCount/ observeOlderUnreadCount added). Undated articles are kept in the main Unread view rather than silently dropped from both. Verified live: 150 recent / 1172 older, summing to the prior combined total. - "Mark read from here": long-press an article row to mark it and everything older in that same filtered list as read in one batch (re-added ArticleDao.markReadBatch, this time as an explicit, confirmed action -- not the implicit on-scroll version that was reverted earlier). Verified live: confirmed a 147-article batch: the tapped article and older were marked read, newer ones above it were not. - BACKLOG.md: added a YouTube-embeds note (JS is off in the reader WebView, so embeds currently show broken -- rough shape is a tap-to-play thumbnail rather than turning JS on for third-party content).
2026-07-22Remove mark-as-read-on-scrollPeter Stone
Explicitly asked to revert: scrolling the article list shouldn't mark anything read on its own -- only opening an article (or the existing manual actions) should. Removed the LazyListState/snapshotFlow tracking in ArticleListScreen and the now-unused ArticleDao.markReadBatch. Verified: unread count in the DB is unchanged after scrolling through the list (1332 before and after).
2026-07-22Split save/Stash actions, follow links in-app via reader pipeline, tune ↵Peter Stone
scroll, fix title entities - ReaderScreen: bookmark icon is now a purely local save (just flips isSaved, no network) -- Stash archiving is a separate explicit action (new cloud icon), since it's a heavier networked action the user should trigger on purpose rather than have bundled into "save." Verified via direct DB queries and (once I stopped misreading small screenshots) the icon updates immediately in-session for both actions. - New ReaderWebView.kt: shared WebView factory for the dark reader palette and link handling, used by both ReaderScreen and the new LinkReaderScreen. Links tapped inside an article's WebView now open in a lightweight in-app reader (extracted via the same Readability pipeline, no DB persistence) instead of falling through to the system browser -- recursive, so a chain of links stays in-app. Verified live: tapping a link mid-article navigated to a real extracted page with a working back stack. - WebView scroll tuning: overscroll glow and scrollbars off, on both the article reader and the new link reader -- aimed at the reported "still a little choppy" scrolling on top of the previous reload-on-recompose fix. - FeedParser: some feeds (kottke.org among them) double-encode titles -- the XML parser resolves outer XML escaping, but leftover HTML entities like "&#8220;" were showing up literally in the title text. Titles are now run through a second HTML-entity decode pass. Verified: a kottke title that previously showed a literal &#8220; now renders the actual curly quote. All changes verified live on the emulator against the real 107-feed dataset.
2026-07-22Smooth scrolling, dark reader, mark-read-on-scroll, feed names, per-feed ↵Peter Stone
reader-mode opt-out - ReaderScreen: AndroidView's update() was reloading the WebView's HTML on every recomposition (e.g. isSaving flipping mid-read), resetting scroll position -- this is what made scrolling feel unsmooth. Now only reloads when the content string actually changes. - ReaderScreen: reader content now uses a hardcoded dark palette instead of browser-default white, matching the app's always-dark theme. - ArticleListScreen: mark-as-read-on-scroll -- once an article has scrolled fully above the viewport it's marked read, the standard feed-reader behavior, via a new batched ArticleDao.markReadBatch(). Verified against the on-device DB (scrolled-past articles flipped to isRead=1, the still-visible one stayed unread). - ArticleListItem/queries: LEFT JOIN feeds for a feedTitle column, shown per row in the Unread/Saved aggregated views and as a reader subtitle; the per-feed article list's top bar now shows the real feed title instead of a generic "Articles". - FeedEntity.readerModeDisabled (schema v3, migration 2->3): per-feed opt-out of the Readability fallback/toggle, toggled from a new icon in FeedListScreen. Added for kottke.org specifically -- confirmed its thin linkblog posts were making Readability extract the page footer instead of the actual post; disabling reader mode for that feed now shows its own (short but correct) content instead. All verified live on the emulator against the real 107-feed dataset.
2026-07-22Fix Unread/startup crash; add reader-mode toggle and swipe navigationPeter Stone
- Root cause of the reported crashes: observeUnread/observeByFeed/ observeSaved used SELECT *, pulling feedContentHtml/extractedContent (potentially large per-article blobs) just to render a list row. With 107 feeds worth of real articles this blows past Android's CursorWindow/memory limits, scaling with item count -- and since Unread is now the landing screen, it crashed at startup too. Added an ArticleListItem projection excluding the heavy content columns; list queries now only select what a row actually renders. - FilterRuleEngine now takes a small Titled interface (title/feedId) instead of the full entity, so it works with both ArticleEntity and the new lighter projection. - Reader-mode toggle: a button in the reader to manually switch between feed-provided content and Readability-extracted full text, matching Inoreader's full-text-view toggle (previously only an automatic fallback when feed content was thin). - Swipe between articles: ReaderScreen now wraps a HorizontalPager over a snapshot of the current list's article-id order, so swiping moves to the next/previous article. Snapshotted rather than live, since marking an article read would otherwise drop it from the live Unread query mid-swipe and shift every later page underneath the user. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
2026-07-22First round of testing feedback: images, unread landing, dark, deletePeter Stone
- Fix OPML import status message reporting the raw pre-dedup count (144) instead of the actual unique feed count after Room's upsert collapses duplicates (108) -- cosmetic bug, the DB was already correct. - Article thumbnails: extract media:thumbnail/enclosure or the first <img> in feed content, show via Coil in the article list. - App now lands on Unread instead of the feed list; a top-bar icon gets from there to the feed list (forward nav, not back-stack pop, since Unread is now the root). - Dark color scheme by default. - Delete-feed action (with confirmation) on each feed row -- was entirely missing. Deletes its non-saved articles too, so they don't linger as orphaned rows in the cross-feed Unread view; saved articles are kept regardless. - Room migration 1->2 for the new imageUrl column (real migration, not destructive fallback, since there's now real on-device subscription data from testing). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
2026-07-22Initial commit: Feeds, an Android RSS readerPeter Stone
Feed subscriptions (RSS 2.0 + Atom, hand-rolled parser), OPML import/export, WorkManager periodic fetch with conditional GET, Room-backed local storage (feeds/articles/filter rules), keyword mute/highlight filtering. Reader mode: on-device extraction via Mozilla's Readability.js (vendored from github.com/mozilla/readability, pinned to release 0.6.0) run in a hidden WebView, for feed articles whose provided content is too thin to be the real article body. Save: explicit action (from a feed article, or the Android share-sheet target for any URL) archives to Stash via POST /artifacts?sync=true, while a local on-device copy (Readability-extracted) is always kept so saved articles read offline regardless of Stash's reachability. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL