| Age | Commit message (Collapse) | Author |
|
The fix before this made site-relative images zoomable by resolving them
against the article's own address. It left the parameter optional, which leaves
the bug one forgetful call site away from coming back -- and coming back
silently, as every image on a site-relative feed quietly refusing to zoom,
which is exactly how it went unnoticed on Ftrain in the first place.
It has no default now. Null is still a legitimate answer, because an article
with no address genuinely cannot resolve anything, but it has to be given on
purpose. A future reader surface that omits it fails to compile rather than
shipping the old behaviour.
Nothing about what the app does changed: the same 157 tests pass, and the six
test call sites that relied on the default now say baseUrl = null, which is
what they always meant.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Tapping an image in a Ftrain article did nothing. The zoom affordance was
there, and the rendering path was the ordinary one -- the feed's own HTML, with
reader mode off -- but a tap went nowhere at all.
Tap detection works by wrapping every article image in a link to a scheme
nothing can navigate to, which the WebView then hands to
shouldOverrideUrlLoading. That href has to carry the complete image URL on its
own, so an image whose src was relative got no wrapper and no tap: better an
image that can't be zoomed, the reasoning went, than a link that goes nowhere.
Ftrain's <content:encoded> is entirely site-relative -- every image in it is
src="/art/uploads/..." -- so not one of them was zoomable.
That reasoning had an unexamined premise. The page itself resolves those srcs
perfectly well, against the base URL loadReaderHtml already hands
loadDataWithBaseURL: the article's own link. Resolving them the same way at
HTML-assembly time produces exactly the URL the WebView went and fetched, so
the wrapper can be built after all. Both reader surfaces now pass that base URL
down to the zoom pass. Where there genuinely is no base URL to resolve
against -- nothing else in the app loads reader HTML without one today -- the
old behavior stands and the image stays un-zoomable rather than acquiring a
dead link, and a data: or javascript: src is still refused however it was
reached, since the viewer could never open one.
This is why the report could only have come from a feed with reader mode off.
Reader mode never hits it: Readability rewrites every src to an absolute URL
before the extracted HTML reaches us, which is also why the same images zoom
fine the moment the feed's reader toggle is on.
TDD: site-relative and path-relative resolution, and the whole trip through
wrapReaderHtml, were written first against the unused parameter and went red on
assertion mismatches (3 of 38 in ImageZoomTest), green on the resolution
itself. The data:-src case was written at the same time and passed immediately
-- it pins existing behavior against the new code path rather than having
driven it.
Verified on the emulator against the reported article itself, subscribed live
from ftrain.com/feed.xml with reader mode off: on the previous build, a tap on
the header image left the screen byte-identical; on this one the same tap at
the same point opens the full-screen viewer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Peter, three builds running: audio plays, YouTube's title, controls and
scrubber thumbnails all draw, and where the picture belongs there is a
blank rectangle.
None of my guesses were right. It is not the embed, not the window, and
not the surface. It is layout. YouTube's player sizes itself with
`body { position: absolute; height: 100% }`, and in this WebView
percentage heights resolve against a containing block of zero height.
Measured in the running app: window.innerHeight correctly reports 214,
while 100vh and every percentage height resolve to 0. The body
collapsed, the player chain under it collapsed, and the video element
was laid out 380x0.
That explains the whole symptom, including the part I kept failing to
account for. The page, the chrome and the stream were all healthy --
the picture simply had nowhere to go, and what showed through the
zero-height player was YouTube's own white <html> background.
So the fix pins the player chain to the pixel height the page does
know and dispatches a resize so YouTube's own code re-measures.
Verified on a device, with the shipped code and nothing injected by
hand: the video element goes 380x0 -> 380x214 and the video plays.
Two things ruled out along the way, recorded so they are not retried:
hardware-accelerating the dialog's window changed nothing, and nor did
moving the player out of a dialog into an in-window overlay. An A/B
against BrowserScreen looked decisive for the window and was not --
that WebView differs in layout too, and layout was the real variable.
This treats the symptom. Why the viewport has no height is below this
app, in WebView.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Peter, on the referrer build: the player "plays and shows controls,
with a white screen instead of video. The scrubber thumbnails pop up,
but the main playback is blank white." His screenshot shows YouTube's
title, gear, play button, timecode and progress bar all drawn
correctly over a blank area.
That narrows it a long way. Everything the page renders -- chrome,
gradients, the storyboard images behind the scrubber -- composites
fine, so the renderer is healthy and the embed is right. The one thing
missing is the only thing that does not come from the page: video is
composited from a separate surface, and that needs the window it sits
in to be hardware accelerated.
The player is a Dialog, which is its own Window. The activity declares
android:hardwareAccelerated, but that does not carry to a Window a
dialog creates for itself, so the flag is now set on it explicitly.
No new logic, so nothing new to unit-test: the URL and referrer this
loads are unchanged and still covered by YouTubeLinksTest. It is a
property of the window the existing WebView lives in.
Unverified here, and this emulator cannot settle it: it has no working
video path at all in a WebView. It hard-crashed the AVD twice on a
plain <video>, and with software decoding forced it survives but
renders a broken-media placeholder rather than a picture. Everything
up to display is confirmed on-device; display is not.
If the picture is still missing, this rules the window out and the
remaining suspect is the surface itself, which means taking the player
out of a floating window entirely.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
Peter, on the previous build: "the inline player now plays the audio but
the video is a black screen." So Error 153 is gone and the stream is
decoding -- what is left is a display problem.
The iframe was not needed in the first place. A `Referer` header alone
satisfies the embed, which means the player can be the WebView's own
ordinary top-level document again. That drops two constructs the last
build added: a `loadDataWithBaseURL` page, whose origin is synthetic,
and a cross-origin subframe inside it. Both have a history of rendering
video oddly in WebView, and the black screen appeared with them.
Measured on an Android user agent -- no referrer: Error 153; `&origin=`
in the query: Error 153; inside an iframe: plays; with a `Referer`
header: plays. The referrer is a constant on a reserved `.invalid`
domain rather than the article's URL, so YouTube learns which app asked
and nothing about what is being read -- the same reason the nocookie
domain is used.
Confirmed in the app's own WebView: the embed loads with no error,
854x480, readyState 4, playing.
NOT confirmed: that a picture now reaches the screen. This emulator
cannot render video in a WebView at all -- it hard-crashed the AVD
twice, including on a plain <video> in an ordinary non-dialog WebView --
so the one thing that matters here can only be checked on real
hardware.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
- 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.
|
|
- 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.
|
|
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.
|
|
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.
|
|
Replaces the 4 remaining copy-pasted StashClient-and-branch call
sites (ReaderScreen x2, LinkReaderScreen x2) with the shared helper
added in the previous commit.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
- 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.
|
|
- 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.
|
|
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.
|
|
- 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).
|
|
- 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.
|
|
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).
|
|
- ReaderScreen: HorizontalPager's full-surface drag detection was treating
ordinary vertical scrolling inside the embedded WebView (a native View,
which doesn't arbitrate with Compose's gesture system the way a
pure-Compose scrollable child would) as an attempted horizontal page-turn
on anything less than a perfectly vertical swipe -- i.e. most real thumb
scrolling. Tried gating it to edge-zone-only drags first, but that
collides with Android's own edge-swipe-back gesture on gesture-nav
devices (reproduced live: an edge swipe triggered system back, not the
pager). Landed on disabling the Pager's built-in gesture and driving it
from a custom detector that works anywhere on screen but only commits to
a page turn once a drag is clearly horizontal-dominant (>2x the vertical
movement) -- ordinary scrolling, even sloppy/diagonal, is left completely
unconsumed for the WebView. Verified live: a deliberately diagonal
mid-screen scroll now stays on the same article; a decisive horizontal
swipe still changes pages.
- LinkReaderScreen: was missing the bookmark/Stash/reader-toggle/menu
actions entirely -- reported after tapping a link left "all my icons...
gone." Brought to parity with the main article reader: save locally and
save to Stash now lazily create a standalone ArticleEntity (feedId=null,
id = hash of the URL, same scheme ShareReceiverActivity uses) on first
use; reader-mode toggle falls back to the live page (JS on) instead of
always forcing extraction; same login/find-another-source menu. Verified
live: all 5 icons present once extraction finishes, bookmarking a linked
article correctly creates a standalone DB row.
|
|
Deliberately not a paywall-bypass mechanism -- that space (archive.today-style
proxies, "Bypass Paywalls" extensions) is legally grey, adversarial toward
publishers, and constantly breaking. Instead:
- BrowserScreen: a real interactive mini-browser (JS + DOM storage on,
ordinary navigation, no Readability interception) for the two cases where
the user needs to actually USE a page rather than read it. Reached from
a new overflow menu on the article reader:
- "Log in to this site" opens the article's own URL there so the user can
sign into a site they already subscribe to. Needs no extra plumbing:
Android's CookieManager is one process-wide store shared by every WebView
in the app, so a session cookie set here is picked up automatically by
the reader's (JS-off) extraction WebView on the next visit to that
domain -- same as a normal browser remembering you're signed in.
- "Find another source" runs a web search on the article's title in the
same browser, so the user can pick a different, non-paywalled outlet
covering the same story themselves -- plain search/discovery, not
circumvention.
Verified live: search opened a real, interactive Google results page with
working title-bar sync; back navigation (via BackHandler, goBack() when the
WebView has history, else pop the screen) returned cleanly to the article.
|
|
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 "“" 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 “ now renders the actual
curly quote.
All changes verified live on the emulator against the real 107-feed dataset.
|
|
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.
|
|
Root-caused via emulator stress test (rapid swiping through many articles):
withTimeoutOrNull's cancellation, and LaunchedEffect disposal when a page is
swiped away, both invoke invokeOnCancellation on kotlinx.coroutines.DefaultExecutor
rather than the calling coroutine's Main dispatcher. The handler called
webView.stopLoading()/destroy() directly, crashing with "A WebView method was
called on thread ..." on essentially every session once enough thin-content
articles were swiped past mid-extraction -- the reported crash loop. Now hops
back to the main looper before touching the WebView, and guards against a
double destroy() race between the JS-callback and cancellation paths.
Also fixed a real (non-crashing) leak: the visible reader WebView in
ReaderScreen was never destroy()-ed when its page left the pager, via
AndroidView's onRelease.
Reproduced pre-fix in ~80 rapid swipes on an emulator; post-fix, 320+ swipes
with real 107-feed data ran clean with stable native heap.
|
|
- 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
|
|
- 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
|