From 3f988c1749460528c31dfc239a3a9544a8f52c30 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Mon, 12 Oct 2026 00:41:24 +0000 Subject: Stop reader mode passing a site's cookie notice off as the article 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 Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B --- .../terst/feedreader/data/CachedExtractionTest.kt | 67 ++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 app/src/test/java/org/terst/feedreader/data/CachedExtractionTest.kt (limited to 'app/src/test/java/org/terst/feedreader/data') diff --git a/app/src/test/java/org/terst/feedreader/data/CachedExtractionTest.kt b/app/src/test/java/org/terst/feedreader/data/CachedExtractionTest.kt new file mode 100644 index 0000000..72dc73f --- /dev/null +++ b/app/src/test/java/org/terst/feedreader/data/CachedExtractionTest.kt @@ -0,0 +1,67 @@ +package org.terst.feedreader.data + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The reported bug came with "extractedContent cached: true", which is the half + * that a fixed extractor cannot reach: the cookie disclaimer was already written + * to `articles.extractedContent`, and every open after that read it straight back + * out without ever looking at it again. + * + * So a poisoned cache has to be recognised and dropped on read, not just kept out + * on write. + */ +class CachedExtractionTest { + + private val cookieNotice = + "

This website uses " + + "cookies and other tracking technologies (“Cookies”) to enhance user experience, to " + + "display targeted ads, and to analyze performance and user activity on our website. For " + + "more information about our use of Cookies and our privacy practices, see our Privacy " + + "Policy. By continuing to use this website, you agree to our Terms and Conditions.

" + + "
" + + @Test + fun `real cached content is used as-is and the cache is left alone`() = runTest { + var cleared = false + val content = "

" + "The actual article body. ".repeat(60) + "

" + val result = loadCachedExtraction(content) { cleared = true } + assertEquals(content, result.content) + assertFalse(result.discardedConsentNotice) + assertFalse("a good cache must not be cleared", cleared) + } + + @Test + fun `a cached consent notice is discarded and deleted`() = runTest { + var cleared = false + val result = loadCachedExtraction(cookieNotice) { cleared = true } + assertNull("the notice must not be shown as the article", result.content) + assertTrue(result.discardedConsentNotice) + assertTrue("the poisoned row must be cleared so a re-extract can replace it", cleared) + } + + @Test + fun `nothing cached touches nothing`() = runTest { + var cleared = false + val result = loadCachedExtraction(null) { cleared = true } + assertNull(result.content) + assertFalse(result.discardedConsentNotice) + assertFalse(cleared) + } + + // A blank value is as good as nothing cached -- it is not a consent notice, so + // reporting it as a discarded one would make the caller re-extract on a false + // premise. + @Test + fun `a blank cached value is not reported as a discarded notice`() = runTest { + var cleared = false + val result = loadCachedExtraction(" ") { cleared = true } + assertFalse(result.discardedConsentNotice) + assertFalse(cleared) + } +} -- cgit v1.2.3