package org.terst.feedreader.ui import android.content.Context import android.content.Intent import android.net.Uri import android.view.View import android.webkit.WebChromeClient import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import org.terst.feedreader.data.DEFAULT_READER_FONT_SIZE_PX // In-memory only (process lifetime, not persisted to disk) -- scoped to "don't lose my // place if Next/Previous takes me away and back," not to surviving an app restart. A // process-wide singleton rather than screen-scoped state so it survives an Activity // recreation (rotation) too, not just Compose recomposition within the same screen instance. object ReaderScrollPositions { private val positions = mutableMapOf() fun get(articleId: String): Int = positions[articleId] ?: 0 fun set(articleId: String, scrollY: Int) { positions[articleId] = scrollY } } private fun isYouTubeWatchUrl(uri: Uri): Boolean { val host = uri.host?.lowercase() ?: return false return host == "youtu.be" || host.endsWith(".youtube.com") || host == "youtube.com" } // Pulls the 11-char video ID out of a youtube.com/watch?v=ID or youtu.be/ID URL. // Returns null for anything it can't confidently resolve to a single video (a // playlist/channel URL, for one) -- callers fall back to the old external-open // behavior in that case rather than guessing. private fun extractYouTubeVideoId(uri: Uri): String? { val id = if (uri.host?.lowercase() == "youtu.be") { uri.pathSegments.firstOrNull() } else { uri.getQueryParameter("v") } return id?.takeIf { it.matches(Regex("^[a-zA-Z0-9_-]{11}$")) } } /** * Shared factory for the read-only, dark-themed article WebView used by both the * per-article ReaderScreen and LinkReaderScreen (for links tapped inside an article). * JS is always off -- extracted/feed HTML never needs it, and this keeps articles * from running arbitrary scripts. Link taps are intercepted rather than left to * WebView's default behavior (handing off to the system browser) so following a * link stays inside the app's own reader/extraction pipeline -- except a YouTube * watch link (what ReaderExtractor turns an embedded video into a thumbnail link * to), which goes straight to the YouTube app/browser instead: there's nothing for * our own Readability-based reader pipeline to usefully extract from a video page. * * onLongPressLink fires for a long-press on a link (a plain one, or an image * wrapped in one, e.g. the YouTube thumbnail) with the target URL, instead of * WebView's own default long-press behavior (a native "open/copy link" popup) -- * callers show their own save/share/open action menu instead. * * onYouTubeVideo fires for a tap on a YouTube watch link (what ReaderExtractor turns * an embedded video into a thumbnail link to) with just the video ID, instead of the * old behavior of handing off to the YouTube app/browser -- callers show an in-app * YouTubePlayerDialog instead, so watching a video doesn't leave the reader. Falls * back to the external-open behavior when a confident video ID can't be extracted * (a playlist/channel link, say). */ fun newReaderWebView( context: Context, onOpenLink: (String) -> Unit, onLongPressLink: (String) -> Unit = {}, onYouTubeVideo: (String) -> Unit = {}, onScrollChanged: (Int) -> Unit = {}, onPageFinished: (WebView) -> Unit = {} ): WebView = WebView(context).apply { settings.javaScriptEnabled = false overScrollMode = View.OVER_SCROLL_NEVER isVerticalScrollBarEnabled = false isHorizontalScrollBarEnabled = false // Reports the WebView's own scroll offset as the user reads, so a caller (ReaderScreen) // can remember how far into an article they'd gotten and restore it -- e.g. after // Next/Previous replaces this screen's back-stack entry with a fresh one for the same // article, which recreates the WebView from scratch and would otherwise always land // back at the top. setOnScrollChangeListener { _, _, scrollY, _, _ -> onScrollChanged(scrollY) } webViewClient = object : WebViewClient() { override fun onPageFinished(view: WebView, url: String?) { super.onPageFinished(view, url) onPageFinished(view) } override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { // A subframe (an """, setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL) ) // The capture group is already constrained to [a-zA-Z0-9_-]{11} by the pattern above, // so it can't contain a `"` or `<` -- safe to splice into the replacement markup as-is. private fun replaceYouTubeIframes(html: String): String = youtubeIframeRegex.replace(html) { match -> val id = match.groupValues[1] "
\"YouTube
▶ Watch on YouTube
" } // Same root cause as the YouTube case above, just not YouTube: any OTHER embeddable // platform's iframe (Twitter/X, Instagram, TikTok, Truth Social, Facebook -- all ship // this way) is equally broken with JS off, since it depends on its own loader script to // actually render anything. Confirmed live: a Truth Social post embed // (""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL) ) private fun replaceGenericIframes(html: String): String = genericIframeRegex.replace(html) { match -> val rawSrc = match.groupValues[1] // Protocol-relative (//host/path) is common in embed markup; anything else must // already be a real http(s) URL -- src is attacker/publisher-controlled feed // content, so a non-http(s) scheme (javascript:, file:, intent:, ...) is dropped // silently rather than ever becoming a clickable link. val href = when { rawSrc.startsWith("//") -> "https:$rawSrc" rawSrc.startsWith("http://", ignoreCase = true) || rawSrc.startsWith("https://", ignoreCase = true) -> rawSrc else -> null } if (href != null) "

View embedded content ↗

" else "" } private fun escapeHtml(text: String): String = text .replace("&", "&") .replace("<", "<") .replace(">", ">") .replace("\"", """) .replace("'", "'") // Hardcoded dark palette, not @media (prefers-color-scheme) -- the app itself always // forces dark colorScheme() regardless of system setting (see MainActivity), so reader // content should match rather than following a system light/dark the app itself ignores. // // title is rendered here, as a real heading at the top of the content, rather than relying // on whatever heading Readability happened to keep in bodyContent (increasingly nothing -- // see ReaderExtractor's
stripping) or on the top app bar's title, which truncates // to one line and was the only place a long title showed up before this. fun wrapReaderHtml( bodyContent: String, title: String? = null, publishedAt: Long? = null, imageUrl: String? = null, fontSizePx: Int = DEFAULT_READER_FONT_SIZE_PX ): String { val processedBody = replaceGenericIframes(replaceYouTubeIframes(bodyContent)) // Some feeds' body HTML never embeds an image at all -- the only image they give us // is a separate RSS media:content/enclosure field (ArticleEntity.imageUrl), which // otherwise only ever reaches the list-view thumbnail and never the reader itself // (confirmed live: a Big Island Now article's had zero tags, // its photo living only in a sibling the body never referenced). Only // show it here when the body truly has nothing of its own, so a feed that DOES embed // its own lead image never ends up with a redundant duplicate above it. // // Checked against the ORIGINAL bodyContent, not processedBody: a YouTube