package org.terst.feedreader.readability import android.annotation.SuppressLint import android.content.Context import android.os.Handler import android.os.Looper import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse 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 ) /** * The outcome of an extraction: either usable content, or a reason it failed. * * extract() used to return a bare ExtractedArticle?, so every distinct failure * — a dead host, an HTTP 403, a timeout, a Readability throw — arrived at the * caller as the same null, and the caller dropped it. */ sealed interface ExtractionResult { data class Success(val article: ExtractedArticle) : ExtractionResult data class Failure(val reason: ExtractionFailure) : ExtractionResult } /** * 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 { // Confirmed live: a heavy, ad/tracker-laden real page (several Ars Technica articles) can // legitimately take longer than 15s to finish loading everything before onPageFinished // fires, causing extraction to time out and silently fall back to no content -- not a // parsing failure, just not enough runway. 25s costs little (a spinner stays up slightly // longer on a truly dead page) against a real reduction in spurious extraction failures. private const val TIMEOUT_MS = 25_000L // onPageFinished fires while the page is still loading -- observed firing at // progress=25 on a perfectly ordinary article. Injecting the script then is a // race: sometimes the DOM is far enough along for Readability to work, and // sometimes evaluateJavascript returns a bare "null" because the document is // still being replaced underneath it. Measured at roughly 2 failures in 6 on // the same URL, which is exactly what a user sees as "extraction randomly // fails on some articles". // // So an unusable result is not final: retry a few times before giving up. // The outer TIMEOUT_MS remains the real backstop. private const val MAX_EVAL_ATTEMPTS = 6 private const val EVAL_RETRY_DELAY_MS = 500L private val json = Json { ignoreUnknownKeys = true } // The vendored asset (~90KB) is identical every time regardless of which Context reads // it -- caching it once avoids a disk read + UTF-8 decode on the MAIN thread (this // whole class runs its WebView work on Dispatchers.Main) on every single extraction, // which adds up over a reading session that opens/re-extracts many articles. No // synchronization needed: extractOnMainThread only ever runs on Dispatchers.Main, so // this is never accessed from more than one thread. private var cachedReadabilityJs: String? = null private fun loadReadabilityJs(context: Context): String = cachedReadabilityJs ?: context.assets.open("Readability.js").bufferedReader().use { it.readText() } .also { cachedReadabilityJs = it } // KaTeX (~267KB vendored, see assets/katex.min.js), cached for the same reason and // under the same single-threaded guarantee as Readability.js above. private var cachedKatexJs: String? = null private fun loadKatexJs(context: Context): String = cachedKatexJs ?: context.assets.open("katex.min.js").bufferedReader().use { it.readText() } .also { cachedKatexJs = it } /** * Extracts article content, or explains why it could not. * * A null from the inner call means the timeout fired, which is a distinct * and common failure (a page whose load never completes) and deserves to * say so rather than look like every other failure. */ suspend fun extractResult(context: Context, url: String): ExtractionResult { val outcome = withTimeoutOrNull(TIMEOUT_MS) { withContext(Dispatchers.Main) { extractOnMainThread(context, url) } } ?: return ExtractionResult.Failure(ExtractionFailure.TimedOut(TIMEOUT_MS)) return outcome } /** Back-compatible shim: content when it worked, null when it did not. */ suspend fun extract(context: Context, url: String): ExtractedArticle? = when (val r = extractResult(context, url)) { is ExtractionResult.Success -> r.article is ExtractionResult.Failure -> null } @SuppressLint("SetJavaScriptEnabled") private suspend fun extractOnMainThread(context: Context, url: String): ExtractionResult = suspendCancellableCoroutine { continuation -> val webView = WebView(context) webView.settings.javaScriptEnabled = true // This WebView runs full JS against whatever URL it's given -- every article // link, every feed URL, and (via ShareReceiverActivity) any URL another // installed app hands this one. It has no legitimate reason to ever load a // file:// URL, so don't leave that door open for a malicious page to redirect // through (WebView defaults allowFileAccess to true pre-API 30). webView.settings.allowFileAccess = false webView.settings.allowContentAccess = false webView.settings.allowFileAccessFromFileURLs = false webView.settings.allowUniversalAccessFromFileURLs = false // A slow-loading page's evaluateJavascript callback can arrive right as the // outer withTimeoutOrNull cancels this coroutine, racing finish() (JS-callback // path) against invokeOnCancellation (timeout path). Both used to call // webView.destroy() unconditionally -- a double destroy() crashes natively. // This flag makes teardown happen exactly once no matter which path wins. var finished = false fun finish(result: ExtractionResult) { if (finished) return finished = true if (continuation.isActive) continuation.resume(result) webView.stopLoading() webView.destroy() } fun fail(reason: ExtractionFailure) = finish(ExtractionResult.Failure(reason)) webView.webViewClient = object : WebViewClient() { override fun onPageFinished(view: WebView, finishedUrl: String) { val readabilityJs = loadReadabilityJs(context) val katexJs = loadKatexJs(context) val script = """ (function() { try { $readabilityJs function plainLength(article) { return (article && article.textContent) ? article.textContent.trim().length : 0; } // A common lazy-load pattern pairs a placeholder with a // sibling