package org.terst.feedreader.ui import android.content.Context import android.content.Intent import android.net.Uri import android.view.ScaleGestureDetector import android.view.View import android.webkit.WebChromeClient import android.webkit.WebResourceRequest import android.view.WindowManager 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.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalView import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogWindowProvider import org.terst.feedreader.data.DEFAULT_READER_FONT_SIZE_PX import org.terst.feedreader.data.UNZOOMED_TEXT_ZOOM_PERCENT import org.terst.feedreader.data.pinchZoomFontSizePx import org.terst.feedreader.data.readerTextZoomPercent // 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. * * onOpenYouTubeExternally fires for a LONG-press on a YouTube link, with the watch URL, instead * of the ordinary onLongPressLink actions menu -- callers hand it to the system, which is the * YouTube app on a phone that has one. A tap plays the video in the reader; a long-press is how * you say you wanted it somewhere else. * * 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). * * onImageTap fires for a tap on an article image (see ImageZoom.kt for how a tap on an * is detected at all with JS off) with the image's own URL -- callers show a full-screen * ImageZoomDialog. Also fires for a link pointing straight at an image file, which used to * send LinkReaderScreen off to run Readability over a JPEG. * * Pinch-to-zoom adjusts the persisted reader font size, not WebView's own page zoom * (never enabled here -- setSupportZoom/builtInZoomControls are left at their defaults, * which is effectively off). A real page zoom would scale layout and images along with * text and wouldn't survive the next article/reload; this app already has a font-size * concept the rest of the reader respects (Settings.readerFontSizePx, adjustable from * the Settings screen), so pinching should drive that same value instead of inventing a * second, WebView-local notion of "how big does this look right now." * * Getting this right without breaking ordinary one-finger scrolling/tapping is the whole * trick: a ScaleGestureDetector is fed every touch via setOnTouchListener, but that * listener always returns false (never consumes the event), so WebView's own touch * handling -- scrolling, link taps, the long-press-link listener above -- proceeds * exactly as before. ScaleGestureDetector only calls back once a second pointer is down, * so a single-finger drag never triggers any of this. * * currentFontSizePx is read fresh (not captured once) at the START of each gesture, since * the WebView this attaches to is created once but can outlive a Settings-screen-driven * font size change made while this same article is still open. Everything during the * gesture is measured against that one starting size, so a pinch is judged against where * it began rather than against a size it's already changed. * * The text reflows live, as the fingers move: each step sets WebSettings.textZoom, which * re-lays-out the text at the new size right away -- no reload, no JavaScript, and * images/padding stay exactly where they are. An earlier version instead scaled the whole * WebView (View.scaleX/scaleY) during the gesture and snapped back to 1x on release, which * zoomed the canvas like a photo (blurring text, magnifying the page's own margins) and * then visibly jumped when the real font size took over. Nothing is scaled now, so there's * nothing to snap back from. * * At gesture end onFontSizeChanged reports the new, clamped, rounded font size for the * caller to persist; ReaderScreen's content-vs-fontSizePx reload logic then re-renders the * HTML at that size via loadReaderHtml, which is also what returns textZoom to 100% -- the * live zoom stays applied until the reload it hands over to is on screen, so the two never * both apply and there's no gap where the text flicks back to its old size. */ data class ReaderWebViewCallbacks( val onOpenLink: (String) -> Unit, val onLongPressLink: (String) -> Unit = {}, val onOpenYouTubeExternally: (String) -> Unit = {}, val onYouTubeVideo: (String) -> Unit = {}, val onImageTap: (String) -> Unit = {}, val onScrollChanged: (Int) -> Unit = {}, val onPageFinished: (WebView) -> Unit = {}, val currentFontSizePx: () -> Int = { DEFAULT_READER_FONT_SIZE_PX }, val onFontSizeChanged: (Int) -> Unit = {} ) /** * The reader's WebView, which knows what font size the HTML it's showing was written at. * * That's the whole reason it's a class rather than a plain WebView: a live pinch lifts * WebSettings.textZoom off 100% and leaves it there until the reload at the new font size * arrives (see the gesture notes above). Anything that loads HTML into this WebView must put * textZoom back, or the lifted zoom multiplies the new, already-bigger CSS size -- so loading * goes through [loadReaderHtml], which does both together and can't be forgotten the way a * separate "and also reset the zoom" line at each call site could. */ class ReaderWebView(context: Context) : WebView(context) { /** The `font-size` the currently-loaded HTML was written with (see wrapReaderHtml). */ var renderedFontSizePx: Int = DEFAULT_READER_FONT_SIZE_PX private set fun loadReaderHtml(baseUrl: String?, html: String, fontSizePx: Int) { renderedFontSizePx = fontSizePx settings.textZoom = UNZOOMED_TEXT_ZOOM_PERCENT loadDataWithBaseURL(baseUrl, html, "text/html", "UTF-8", null) } } fun newReaderWebView(context: Context, callbacks: ReaderWebViewCallbacks): ReaderWebView = ReaderWebView(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, _, _ -> callbacks.onScrollChanged(scrollY) } var pinchScale = 1f var pinchBaseFontSizePx = DEFAULT_READER_FONT_SIZE_PX val webView = this val scaleDetector = ScaleGestureDetector( context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() { override fun onScaleBegin(detector: ScaleGestureDetector): Boolean { pinchScale = 1f pinchBaseFontSizePx = callbacks.currentFontSizePx() return true } override fun onScale(detector: ScaleGestureDetector): Boolean { pinchScale *= detector.scaleFactor // Steps in whole pixels of font size, not in raw gesture scale, so a slow // pinch relayouts a handful of times over its whole range rather than on // every touch-move frame -- and lands on exactly the size it will persist. val targetPx = pinchZoomFontSizePx(pinchBaseFontSizePx, pinchScale) webView.settings.textZoom = readerTextZoomPercent(webView.renderedFontSizePx, targetPx) return true } override fun onScaleEnd(detector: ScaleGestureDetector) { callbacks.onFontSizeChanged(pinchZoomFontSizePx(pinchBaseFontSizePx, pinchScale)) } } ) setOnTouchListener { _, event -> scaleDetector.onTouchEvent(event) false // never consume -- WebView still handles scroll/tap/long-press normally } webViewClient = object : WebViewClient() { override fun onPageFinished(view: WebView, url: String?) { super.onPageFinished(view, url) callbacks.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)) // NB: linkifyImagesForZoom runs at the very end (below, over hero + body together), after // the iframe passes -- the YouTube thumbnail one of them synthesizes is an image inside a // link, and the zoom pass has to see it as one to leave its tap-to-play link alone. // 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