From fd49bacfb92ef8d29cbad56419acd016ff40f357 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sat, 22 Aug 2026 01:00:02 +0000 Subject: In-app YouTube playback, shared ConfirmDialog, hardened link handling - ReaderWebView/LinkReaderScreen/ReaderScreen: tapping a YouTube link inside an article now opens an in-app player (youtube-nocookie.com, JS scoped to just that dialog) instead of handing off to the YouTube app/browser. Falls back to external-open when a confident video ID can't be extracted (playlist/channel links). - A subframe (ad/embed iframe) auto-navigating is now blocked outright instead of being treated like a real link tap -- it fired the exact same callback as a user tap, with no interaction required. - wrapReaderHtml can now show a fallback hero image + date when the feed's own body HTML has no image of its own. - ConfirmDialog: the generic "confirm before doing something irreversible" dialog shape was independently rebuilt from scratch at FeedListScreen's unsubscribe and ArticleListScreen's mark-read-from- here confirmations. Both now share one composable; the latter also chunks its batch update (markReadBatchChunked) so a long-unread feed can't exceed SQLite's per-statement bound-variable limit. --- .../java/org/terst/feedreader/ui/ReaderWebView.kt | 239 ++++++++++++++++++++- 1 file changed, 234 insertions(+), 5 deletions(-) (limited to 'app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt') diff --git a/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt b/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt index e69dd9b..f0873c8 100644 --- a/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt +++ b/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt @@ -4,23 +4,43 @@ 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 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). @@ -36,8 +56,20 @@ private fun isYouTubeWatchUrl(uri: Uri): Boolean { * 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 = {}): WebView = +fun newReaderWebView( + context: Context, + onOpenLink: (String) -> Unit, + onLongPressLink: (String) -> Unit = {}, + onYouTubeVideo: (String) -> Unit = {} +): WebView = WebView(context).apply { settings.javaScriptEnabled = false overScrollMode = View.OVER_SCROLL_NEVER @@ -45,9 +77,24 @@ fun newReaderWebView(context: Context, onOpenLink: (String) -> Unit, onLongPress isHorizontalScrollBarEnabled = false webViewClient = object : WebViewClient() { 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("<", "<") @@ -117,12 +319,36 @@ private fun escapeHtml(text: String): String = text // 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): String = """ +fun wrapReaderHtml(bodyContent: String, title: String? = null, publishedAt: Long? = null, imageUrl: String? = null): 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