summaryrefslogtreecommitdiff
path: root/app/src/main/java
AgeCommit message (Collapse)Author
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-20Fix intermittent extraction failures, and stop them being silentPeter Stone
Bug report: extraction failed on one 1500days.com article while others worked. It is not that article. Extraction was racing the page load, and the report could say nothing more because the app discarded the reason. Two separate defects. 1. The race. onPageFinished fires before the page has finished loading — observed at progress=25 on this article — and the extraction script was injected there. Sometimes the DOM is far enough along; sometimes evaluateJavascript hands back a bare "null" because the document is still being replaced. Measured against the reported URL: 4 successes in 6 attempts before, 10 in 10 after. An unusable result is no longer final. A null or empty-content result is retried (6 attempts, 500ms apart, well inside the existing 25s timeout); a Readability error still fails immediately, since retrying will not change what the parser thinks of the markup. 2. The silence. ReaderScreen did `extracted?.content?.let { }`, so every failure — dead host, HTTP error, timeout, parser throw — arrived as the same null and was dropped. The reader just kept showing raw feed content. The only way a failure ever surfaced was the user noticing. extractResult() now returns a typed ExtractionFailure carrying the reason; ReaderScreen logs the diagnostic and shows the user a short explanation. Also replaced a dead error callback. The only override was the four-argument onReceivedError, which Android stopped calling at API 23; minSdk is 26, so it never fired on any supported device. A failed load therefore invoked nothing and hung for the full 25 seconds before surfacing as an indistinguishable null. Now overrides the request/error and onReceivedHttpError forms Android actually calls, both filtered to the main frame so a 404'd tracker cannot abort an article that renders fine. The instrumented test does NOT reproduce the race and says so in its own docstring: a MockWebServer page finishes loading before onPageFinished fires, and slow subresources do not help because WebView waits for them. It is kept as a reliability guard over repeated back-to-back extractions. The race was verified by hand against the live URL. 47 unit tests, 1 instrumented test, both green.
2026-09-18Tap an article image to open it full-screen, with pinch/double-tap zoomPeter Stone
Article images could be looked at but never enlarged -- a photo or a chart in a feed was stuck at whatever size the reader column gave it. Tapping one now opens a full-screen viewer: pinch to zoom (up to 6x), double-tap to toggle between fitted and 3x, drag to pan while zoomed, and dismiss with back, the close button, or a swipe down. Detecting the tap is the whole difficulty. The reader is a WebView, and its JavaScript is off deliberately (untrusted article content), so no in-page script can notice a tap on an <img> and report it out. WebView's own built-in zoom isn't the answer either: pinching in the reader is already taken, on purpose, by the reader-font-size gesture, and page zoom scales the article's whole layout rather than giving an image the screen. So wrapReaderHtml now wraps every image that isn't already inside a link in a link to a scheme nothing can navigate to (feedreader-image:<url>), which WebView hands to shouldOverrideUrlLoading -- the interception point that already routes ordinary links and YouTube thumbnails. The new onImageTap callback opens ImageZoomDialog instead of following it. Images that already sit inside a link are left alone: ReaderExtractor's synthesized YouTube thumbnail is exactly that shape, and re-wrapping it would swallow the tap that plays the video. Those stay zoomable from the other side -- a link pointing straight at an image file is also treated as a zoom target, which additionally fixes WordPress's default "link to media file" markup sending a tap off to LinkReaderScreen to run Readability over a JPEG. The long-press-link handler strips the scheme back off, so the actions menu still offers the real image URL. The viewer is Compose + Coil (AsyncImage), matching what the article list's thumbnails already use, so a just-scrolled-past image usually comes straight from Coil's cache; zoom is a graphicsLayer transform, not a re-decode. Panning is bounded by where the image actually is rather than by the viewport -- ContentScale.Fit letterboxes anything that isn't the screen's aspect ratio, and bounding by the viewport would let a wide photo be dragged up into its own black bar. Coil reports the intrinsic size once decoded; until then the viewport is the fallback bound. TDD: the HTML rewriting (linkifyImagesForZoom), the tap-target decision (zoomableImageTarget), and the zoom/pan math (imageZoomScale, imageZoomDoubleTapScale, clampImagePanOffset, fittedImageSize) were all written test-first against stubs, red on assertion mismatches, green on the real implementations -- 26 new cases in ImageZoomTest, including three over wrapReaderHtml itself to pin the ordering that keeps the YouTube thumbnail's link intact. Gesture wiring and the dialog itself are Compose UI, untestable here for the same reason the rest of ui/ is. Coverage is unchanged: ui/ is excluded from the Jacoco metric by design.
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-24Document the bug-report clipboard tool as intentional, not unfinishedPeter Stone
An audit flagged it as looking half-built (builds a full diagnostic report but has no submission endpoint). Confirmed intentional: no backend exists to submit to on this single-user app, and the real destination is pasting it into a message during the normal hands-on-testing workflow. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017GMEkHeqKz6FLkmizowBTK
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-24Let filter rules be scoped to one feed; fix coverage-gate report path bugPeter Stone
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017GMEkHeqKz6FLkmizowBTK
2026-08-24Pre-extract reader-mode content during feed fetch, not on article openPeter Stone
Feeds like West Hawaii Today need Readability to strip a paywall/ boilerplate wrapper, and that extraction was happening lazily the first time an article was opened -- a hidden WebView load + JS eval that left the reader sitting on a spinner. Do it for new articles from reader-mode feeds right inside FeedFetchWorker.fetchOne instead, so it's already cached (both on the launch fetch and the 5am morning fetch) by the time the article is actually opened. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017GMEkHeqKz6FLkmizowBTK
2026-08-23Restore scroll position within an article on Next/PreviousPeter Stone
Next/Previous replaces this screen's own back-stack entry with a fresh one for the neighboring article, which recreates the WebView from scratch -- going back to an article you'd been partway through always landed you back at the top. Tracks each article's scroll offset in an in-memory, process-lifetime map (ReaderScrollPositions) as the WebView scrolls, and restores it once the freshly-loaded page finishes laying out.
2026-08-23Preserve scroll position when the article list changes underneath youPeter Stone
A background fetch (the 5am alarm or the refresh button) inserting new articles above the current viewport left LazyColumn pointing at the same numeric index, which after the insert resolves to a different (newer) article -- the visible list silently jumped forward instead of staying where you were reading. Re-anchors to the same article by id at whatever index it now sits at, restoring the exact scroll offset too.
2026-08-22Restore the 5am morning-fetch alarmPeter Stone
Reverts part of the previous commit: the explicit refresh button and fetch-on-launch stay, but the user still wants the background 5am alarm so articles are fresh before the app is opened, not just the periodic hourly WorkManager job.
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-22Replace background auto-refresh with fetch-on-launch + explicit buttonPeter Stone
Periodic hourly WorkManager fetches and the 5am exact-alarm morning fetch both refreshed without any visible trigger, which surprised the user more than it helped. Refreshing now only ever happens on a fresh app launch (MainActivity.onCreate) or an explicit tap of the new refresh button on the Unread/Older screens' top bar (wired to the fetch-status state that was already being computed there but never rendered). Drops MorningFetchReceiver entirely along with its exact-alarm permission and boot receiver.
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-22BrowserScreen: don't accept third-party cookiesPeter Stone
This screen exists so a site's own first-party login session cookie survives into the reader's later extraction requests -- that's all the login-persistence feature needs. Third-party cookies are for cross-site trackers embedded in whatever page is loaded here, which this feature has no use for and which only adds tracking surface shared with every other WebView in the app.
2026-08-22Add a 5am morning-fetch alarm so articles are fresh before you open the appPeter Stone
FeedFetchWorker's periodic 60-minute WorkManager job can (and does) get silently deferred for hours by Doze on an idle overnight device. An exact alarm (setExactAndAllowWhileIdle) wakes the device at a fixed 5am to run a fetch, without requesting the much broader "ignore battery optimizations" exemption -- battery optimization stays on otherwise, just this one daily wake-up. Android 12+ requires a one-time user grant for exact alarms (no in-app permission dialog); MainActivity requests it via Settings if not already granted, tracked across rotation via savedInstanceState so a config change doesn't re-trigger the Settings screen. The alarm also gets re-armed on BOOT_COMPLETED, since AlarmManager alarms don't survive a device restart on their own.
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-22Fix reader rotation bug: replace-nav instead of a swipe-queue snapshotPeter Stone
The reader used to snapshot the article-id order of its list ONCE at entry and page a HorizontalPager across that fixed queue. Two real bugs traced back to one root cause: the snapshot lived in plain remember state that did NOT survive an orientation change, while the pager's own page index did -- rotating mid-session left the index pointing into a freshly re-fetched (and by then shorter, since paging past an article marks it read and drops it from the Unread query) list, landing on a different article than the one actually being read. Back was confusing for the same reason: it always returned to wherever the ORIGINAL initialArticleId pointed, not wherever the pager had since scrolled to. Fixed by not having a separate queue/index at all: each Next/Previous now replaces this screen's own back-stack entry with a fresh navigation to the neighboring article's id (MainActivity wires onNavigateToArticle with the same popUpTo-replace pattern LinkReaderScreen already uses for its own link chain). "Which article is showing" is now the nav route argument itself -- Android's own Saved State mechanism, not custom state that can drift out of sync with it -- and the back stack is always exactly [list, one reader entry] no matter how many articles were paged through.
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-22Require explicit tap before ShareReceiverActivity saves anythingPeter Stone
This activity is exported (any installed app can target it directly with an ACTION_SEND intent, not just via the system share sheet). Starting extraction/Stash-save immediately on launch meant any other app could silently make this one fetch+JS-render an arbitrary URL and archive it to the user's own Stash server with zero user interaction. A tap is now the only thing standing between "another app sent an intent" and "real network work happens." Also switches to the shared saveArticleToStash helper while touching this same code path.
2026-08-22Adopt shared saveArticleToStash in ReaderScreen and LinkReaderScreenPeter Stone
Replaces the 4 remaining copy-pasted StashClient-and-branch call sites (ReaderScreen x2, LinkReaderScreen x2) with the shared helper added in the previous commit.
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-22StashClient: cancellable requests, longer sync timeout, defensive parsingPeter Stone
Uses the new Call.await() instead of a thread-blocking execute(). The sync=true save endpoint can take up to ~30s (server-side extract + summary), longer than the shared client's stock 10s read timeout -- overridden per-call so feed fetches don't inherit a 35s timeout they don't need. A 409 (duplicate) is authoritative from the status code alone; a body that doesn't match DuplicateErrorResponse's exact shape no longer downgrades a real "already saved" into a misleading "save failed".
2026-08-22Harden extraction WebView, cache Readability.js, sharpen YouTube thumbsPeter Stone
- Cache the ~90KB Readability.js asset read (identical every time) instead of a disk read + UTF-8 decode on the main thread for every single extraction. - Lock down allowFileAccess/allowContentAccess/allowUniversalAccess: this WebView runs full JS against any article link, feed URL, or (via ShareReceiverActivity) any URL another app hands it -- no legitimate reason to ever load file://. - hq720 thumbnail instead of hqdefault (a letterboxed 4:3 crop). - Validate frameSrc is actually http(s) before using it verbatim as a fallback link -- it's attacker-controlled article content that only had to match a substring filter, not the strict video-ID regex, so a crafted file://, content://, or intent:// src couldn't ride along into an ACTION_VIEW intent or a JS-enabled WebView.loadUrl.
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-30Merge Ars Technica's split .post-content blocks before Readability scores themPeter Stone
Ars splits a longer article's body across multiple sibling .post-content.post-content-double divs (with a mid-content ad rail between them) instead of one continuous container. Readability scores each as an independent candidate and keeps only the highest-scoring one, silently dropping the rest -- confirmed live on "Reaction wheel failures leave Swift rescue mission spinning in orbit", which lost its first 7 paragraphs (lede through the reaction-wheel explainer). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Zwte3cyUJFUVycc3sDmjM
2026-07-30Clamp WordPress caption wrappers so inline images stay in-columnPeter Stone
img { max-width: 100% } only bounded the <img> itself; a WordPress <figure class="wp-caption" style="width: 448px"> wrapper sized for the source site's column was left unconstrained, so the wrapper (and the image filling it) overflowed the reader's own narrower column. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Zwte3cyUJFUVycc3sDmjM
2026-07-25Fix BrowserScreen back button: step through page history before closingPeter Stone
Reasoned through the code rather than reproducing live (per explicit instruction). An earlier change made back always close the whole browser screen in one press, regardless of how many pages deep the session was -- on the theory that stepping back one page at a time through a long search/login session was tedious. That trade was wrong: it meant a single accidental back press (system gesture or the in-app arrow) could silently destroy an entire multi-page browsing session with no way to recover it, and it doesn't match how every other browser or in-app browser view on the platform behaves. Now: go back through the WebView's own page history first (canGoBack()/goBack()), and only close the screen once there's nowhere left to go back to. Both the system back gesture and the in-app top-bar arrow route through the same handleBack(), so they behave identically. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
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-24Add a "keep unread" skip button between Previous and NextPeter Stone
Advances to the next article the same as Next, but without the implicit mark-read that normally comes with moving on -- for "I looked, but I'm not done with this one yet." Verified live: skipping past an article leaves it unread while the queue still advances; regular Next still marks read as before. 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-23Strip <header> chrome before Readability parses; show full article title in ↵Peter Stone
reader content - Confirmed live (Ars Technica): a section-kicker badge, the dek/subhead, and an orphaned duplicate hero-image caption were all leaking into extracted content because they live in a <header> Readability doesn't exclude by tag, only deprioritizes in scoring. Strip <header> from the DOM ourselves before handing off to Readability -- a pre-processing decision on our side, not a change to Readability's own algorithm, per instruction not to touch it. - Render the article's full title as a heading at the top of the reader content itself (wrapReaderHtml), not just the top bar, which truncates to one line. This also replaces whatever heading Readability happened to keep in-body, now that <header> (and any <h1> inside it) is stripped pre-parse. - Bump extraction timeout 15s -> 25s: confirmed live, several heavy real-world pages were timing out before onPageFinished ever fired, well short of a real parsing failure. 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-23Drop swipe-to-next-article; bottom Previous/Next bar; smaller titles; bug reportPeter Stone
- Dropped swipe-to-next-article entirely, per explicit instruction. Two different fixes for the gesture interfering with WebView scrolling had already been tried and didn't resolve the reported jerkiness (one even broke swipe outright and was reverted) -- rather than keep tuning gesture detection, removed the gesture altogether. - ReaderScreen: new bottom bar with explicit Previous/Next buttons (disabled at the ends of the article queue), replacing swipe. This is the first slice of the "bottom action strip" backlog item. Verified live: Previous/Next correctly page through the article queue with the right enabled/disabled state at each end. - Smaller title text: reader top bar title (titleMedium, was TopAppBar's default titleLarge) and article list row titles (bodyMedium, was ListItem's default bodyLarge). - New "Report bug" action in the reader's overflow menu: a dialog for a short description, which gets bundled with the article's title/URL/id, feed id, reader-mode state, and save/Stash state into one text block copied to the clipboard on confirm (verified live, including the "Copied to clipboard" confirmation) -- enough to reproduce an issue in the tungsten emulator without needing live device access. - BACKLOG.md: marked the done slice of the bottom-strip item, added a note on the scroll-jerkiness investigation so far (two ruled-out theories) since swipe removal may or may not affect it -- worth a fresh look now that nothing intercepts touches above the WebView at all.
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-22Resolve lazy-loaded images/media before running ReadabilityPeter Stone
Many sites lazy-load media via JS: the real URL sits in a data-src/ data-srcset attribute and only gets copied into src/srcset once the site's own script runs -- which never happens in the reader WebView (JS is off by design, so articles can't run arbitrary third-party scripts). Left alone this showed either a placeholder/category icon stretched to fill the article width (reported live with a screenshot: a giant flask icon in place of the real photo on an Ars Technica article), or nothing but alt text (reported for NYT articles' images and videos). Before either Readability pass, walk <img>/<source> elements and copy common lazy-load attributes (data-src, data-original, data-lazy-src, data-srcset) into src/srcset, falling back to the first srcset candidate if src is still empty. Also strip width/height attributes on images -- they're sized for the site's real photo, not whatever was in the unresolved src, so our own max-width:100%/height:auto sizing now takes over based on the (correctly resolved) image's actual dimensions. Verified live: the Ars Technica article that previously showed the stretched flask icon now renders clean, with no image where the broken one used to appear and the full real article text intact.
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-22Un-hide CSS-gated content before running ReadabilityPeter Stone
Some "soft" paywalls (West Hawaii Today's included) deliver the full article in the initial HTML response to every visitor, then hide it with CSS (inline or via stylesheet) pending a client-side subscription check -- the content itself isn't withheld by the server, only its visibility. Readability has a built-in heuristic that skips anything hidden this way, treating it as boilerplate, which is exactly what was discarding real, already-delivered article text and leaving only the visible teaser/ subscribe prompt. Before parsing, walk the live document (getComputedStyle needs an attached node) and force display/visibility back on for anything hidden via display:none or visibility:hidden, then clone and hand that off to Readability as before. Verified live against a real West Hawaii Today article: extractedContent went from 588 characters (the RSS teaser) to 3458 (the full body).
2026-07-22Fix scroll regression and feed-setting reset on re-importPeter Stone
- ReaderScreen: the direction-ratio gesture rewrite (using a generic, non-oriented detectDragGestures to compute a custom dx/dy ratio) broke scrolling entirely -- reported live as "the article view doesn't scroll at all anymore." Root cause: detectDragGestures' touch-slop detection is isotropic (claims the first bit of movement in ANY direction before it knows which way the gesture is going), unlike detectHorizontalDragGestures which is orientation-aware and simply never engages for a vertical- dominant drag. Reverted to detectHorizontalDragGestures, keeping the distance-based (not edge-anchored) commit threshold from the previous fix. Verified live: a long article now scrolls properly on a diagonal swipe, and a deliberate horizontal swipe still changes pages. - AddFeedScreen: both the OPML import and single-URL add built a bare FeedEntity(id, title, siteUrl) and upserted it (REPLACE) even when a feed with that id already existed -- silently wiping readerModeDisabled (and etag/lastModified/lastError) back to defaults on every re-import or re-add. This is exactly what made a feed's reader-mode toggle appear not to persist. Now preserves the existing row via .copy() when one exists. Verified live: set kottke.org's readerModeDisabled=1, re-imported the same OPML file (107 feeds), and confirmed the flag was still 1 afterward.
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).