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