summaryrefslogtreecommitdiff
path: root/app/src/main/java/org/terst/feedreader/readability
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-07-22 08:08:58 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-07-22 08:08:58 +0000
commitd8ce4b3c0f383e844b125d2d3abd618715bb3c89 (patch)
treeb586ff9b42392f2c44645773c2a2f2aadb9db406 /app/src/main/java/org/terst/feedreader/readability
Initial commit: Feeds, an Android RSS reader
Feed subscriptions (RSS 2.0 + Atom, hand-rolled parser), OPML import/export, WorkManager periodic fetch with conditional GET, Room-backed local storage (feeds/articles/filter rules), keyword mute/highlight filtering. Reader mode: on-device extraction via Mozilla's Readability.js (vendored from github.com/mozilla/readability, pinned to release 0.6.0) run in a hidden WebView, for feed articles whose provided content is too thin to be the real article body. Save: explicit action (from a feed article, or the Android share-sheet target for any URL) archives to Stash via POST /artifacts?sync=true, while a local on-device copy (Readability-extracted) is always kept so saved articles read offline regardless of Stash's reachability. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EZ7ikw2ukGJFTHE3bJS7zL
Diffstat (limited to 'app/src/main/java/org/terst/feedreader/readability')
-rw-r--r--app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt95
1 files changed, 95 insertions, 0 deletions
diff --git a/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt b/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt
new file mode 100644
index 0000000..9be08b0
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/readability/ReaderExtractor.kt
@@ -0,0 +1,95 @@
+package org.terst.feedreader.readability
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import kotlin.coroutines.resume
+
+@Serializable
+data class ExtractedArticle(
+ val title: String? = null,
+ val content: String? = null, // HTML
+ val textContent: String? = null,
+ val excerpt: String? = null,
+ val error: String? = null
+)
+
+/**
+ * Runs Mozilla's Readability.js (vendored in assets/Readability.js, pinned
+ * to release 0.6.0) against a page loaded in a hidden WebView, for feed
+ * articles whose feedContentHtml is missing or too short to be the real
+ * article body. WebView APIs are main-thread-only, so this hops to
+ * Dispatchers.Main for the load+eval and suspends the calling coroutine
+ * until the JS callback fires (or the timeout elapses).
+ */
+object ReaderExtractor {
+ private const val TIMEOUT_MS = 15_000L
+ private val json = Json { ignoreUnknownKeys = true }
+
+ suspend fun extract(context: Context, url: String): ExtractedArticle? =
+ withTimeoutOrNull(TIMEOUT_MS) {
+ withContext(Dispatchers.Main) {
+ extractOnMainThread(context, url)
+ }
+ }
+
+ @SuppressLint("SetJavaScriptEnabled")
+ private suspend fun extractOnMainThread(context: Context, url: String): ExtractedArticle? =
+ suspendCancellableCoroutine { continuation ->
+ val webView = WebView(context)
+ webView.settings.javaScriptEnabled = true
+
+ fun finish(result: ExtractedArticle?) {
+ if (continuation.isActive) continuation.resume(result)
+ webView.stopLoading()
+ webView.destroy()
+ }
+
+ webView.webViewClient = object : WebViewClient() {
+ override fun onPageFinished(view: WebView, finishedUrl: String) {
+ val readabilityJs = context.assets.open("Readability.js").bufferedReader().use { it.readText() }
+ val script = """
+ (function() {
+ try {
+ $readabilityJs
+ var article = new Readability(document.cloneNode(true)).parse();
+ return JSON.stringify(article);
+ } catch (e) {
+ return JSON.stringify({error: e.toString()});
+ }
+ })();
+ """.trimIndent()
+
+ view.evaluateJavascript(script) { rawResult ->
+ // evaluateJavascript's callback value is itself JSON-encoded (our
+ // JSON.stringify(...) string comes back wrapped in an extra layer
+ // of JSON string-quoting), so this needs decoding twice.
+ val unwrapped = runCatching { json.decodeFromString<String>(rawResult) }.getOrNull()
+ val article = unwrapped?.let {
+ runCatching { json.decodeFromString<ExtractedArticle>(it) }.getOrNull()
+ }
+ finish(article)
+ }
+ }
+
+ override fun onReceivedError(
+ view: WebView,
+ errorCode: Int,
+ description: String?,
+ failingUrl: String?
+ ) {
+ finish(ExtractedArticle(error = description ?: "Failed to load $failingUrl"))
+ }
+ }
+
+ continuation.invokeOnCancellation { webView.destroy() }
+ webView.loadUrl(url)
+ }
+}