summaryrefslogtreecommitdiff
path: root/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt')
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt239
1 files changed, 234 insertions, 5 deletions
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 <iframe> in the article body, e.g. a non-YouTube oEmbed
+ // like Twitter/Instagram, or an ad) fires this same callback the instant
+ // its src resolves -- not a user tap. Without this check, that auto-fired
+ // navigation was treated exactly like a real link tap: an ad/embed iframe
+ // could silently auto-navigate to LinkReaderScreen the moment an article
+ // opened, no interaction required. Block subframe navigation outright
+ // (return true = don't load it) rather than following it anywhere; JS is
+ // off in this WebView regardless, so almost nothing embeddable would have
+ // worked here anyway.
+ if (!request.isForMainFrame) return true
val url = request.url
if (isYouTubeWatchUrl(url)) {
- context.startActivity(Intent(Intent.ACTION_VIEW, url))
+ val videoId = extractYouTubeVideoId(url)
+ if (videoId != null) {
+ onYouTubeVideo(videoId)
+ } else {
+ context.startActivity(Intent(Intent.ACTION_VIEW, url))
+ }
} else {
onOpenLink(url.toString())
}
@@ -69,6 +116,28 @@ fun newReaderWebView(context: Context, onOpenLink: (String) -> Unit, onLongPress
}
}
+// The generic "confirm before doing something you can't undo" shape -- title, body text,
+// a labeled confirm action, and a Cancel that just dismisses -- independently rebuilt from
+// scratch at FeedListScreen's unsubscribe confirmation and ArticleListScreen's mark-read-
+// from-here confirmation before this. A third such dialog (there's real incentive for one,
+// e.g. deleting a saved article) would very likely have been a third from-scratch copy.
+@Composable
+fun ConfirmDialog(
+ title: String,
+ text: String,
+ confirmLabel: String,
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(title) },
+ text = { Text(text) },
+ confirmButton = { TextButton(onClick = onConfirm) { Text(confirmLabel) } },
+ dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
+ )
+}
+
@Composable
fun LinkActionsDialog(
url: String,
@@ -102,6 +171,139 @@ fun LinkActionsDialog(
)
}
+// A single-video, throwaway playback surface -- the only place in the reader JS is
+// ever turned on. Scoped to exactly the video the user tapped "play" on (which is
+// also the user gesture that makes mediaPlaybackRequiresUserGesture = false safe
+// here specifically: nothing autoplays until the user has already asked for this
+// exact video), and destroyed on dismiss so nothing keeps running in the background.
+// Loads youtube-nocookie.com, not youtube.com -- YouTube's own privacy-enhanced embed
+// domain, deliberately chosen so a casual video view doesn't pull in the viewer's
+// signed-in YouTube account/history the way the full site or app would.
+@Composable
+fun YouTubePlayerDialog(videoId: String, onDismiss: () -> Unit) {
+ // usePlatformDefaultWidth defaults to true, which caps the dialog at the platform's
+ // narrow AlertDialog-style width -- fine for text, but it squeezed our own
+ // fillMaxWidth().aspectRatio(16f/9f) player down to a small, oddly-placed box
+ // instead of an actual video-sized player (confirmed live: exactly this box, too
+ // narrow, sitting off-center). Opt out so fillMaxWidth below means the real
+ // screen width, with our own padding controlling the margin instead.
+ Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
+ AndroidView(
+ modifier = Modifier.fillMaxWidth().padding(16.dp).aspectRatio(16f / 9f),
+ factory = { ctx ->
+ // A debug build only -- lets `chrome://inspect` on a USB-connected computer
+ // attach to this exact WebView and show its real JS console output. Blank/
+ // black-then-white symptoms alone don't say WHY the embed's player script is
+ // failing; this is how to actually see the error next time instead of
+ // guessing from a screenshot.
+ if ((ctx.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0) {
+ WebView.setWebContentsDebuggingEnabled(true)
+ }
+ WebView(ctx).apply {
+ settings.javaScriptEnabled = true
+ settings.mediaPlaybackRequiresUserGesture = false
+ // YouTube's embed player is itself a JS app that reads/writes
+ // localStorage during init -- without DOM storage enabled, that
+ // throws and the player never mounts, leaving a blank page (confirmed
+ // live: exactly the black-flash-then-blank-white reported). Enabling
+ // it here is scoped to this one throwaway playback WebView, not the
+ // reader WebView the rest of the app uses.
+ settings.domStorageEnabled = true
+ // Without these the embed page can render at the wrong scale (WebView's
+ // default is to lay out as if for a desktop viewport, then shrink it to
+ // fit -- the player area ends up small and centered in its own black
+ // background, which is what the reported "letterboxed a bit" was:
+ // genuine video area, just laid out far smaller than the WebView itself).
+ settings.useWideViewPort = true
+ settings.loadWithOverviewMode = true
+ settings.setSupportZoom(false)
+ settings.builtInZoomControls = false
+ // The embed page sets its own first-party cookies for session/playback
+ // state even on the nocookie domain (nothing to do with tracking --
+ // that's what the domain choice already avoids); WebView blocks
+ // third-party cookies by default but not first-party ones, so this is
+ // belt-and-suspenders rather than the primary fix.
+ android.webkit.CookieManager.getInstance().setAcceptCookie(true)
+ setBackgroundColor(android.graphics.Color.BLACK)
+ // This dialog has no address bar, no back button, and nothing visually
+ // marking it as a WebView at all -- it's meant to look like a trusted
+ // video player, not a browser. Without a WebViewClient restricting
+ // navigation, an in-page redirect (an end-screen link, a compromised ad
+ // within the embed, any script-driven top-level navigation) would load
+ // right in place with JS still on and nothing stopping it going
+ // anywhere. Confine main-frame navigation to YouTube's own domains;
+ // anything else is blocked rather than followed.
+ webViewClient = object : WebViewClient() {
+ override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
+ if (!request.isForMainFrame) return false
+ val host = request.url.host?.lowercase() ?: return true
+ val allowed = host == "youtube-nocookie.com" || host.endsWith(".youtube-nocookie.com") ||
+ host == "youtube.com" || host.endsWith(".youtube.com") || host == "youtu.be"
+ return !allowed
+ }
+ }
+ webChromeClient = WebChromeClient()
+ loadUrl("https://www.youtube-nocookie.com/embed/$videoId?autoplay=1&playsinline=1")
+ }
+ },
+ onRelease = { it.destroy() }
+ )
+ }
+}
+
+// Readability-extracted content never has a raw <iframe> left in it -- ReaderExtractor
+// already swaps every YouTube iframe for this same thumbnail-link shape before
+// Readability ever runs. But a feed's raw feedContentHtml (shown as-is whenever a
+// feed's reader mode is off) can itself contain a literal YouTube <iframe> -- standard
+// WordPress oEmbed markup does exactly this (confirmed live on a kottke.org article).
+// Since the reader WebView always has JS off, that raw iframe hits YouTube's own no-JS
+// fallback and renders as a broken "enable JavaScript" error box instead of anything
+// useful. Give raw feed content the same treatment extracted content already gets.
+// Applying this to EVERY call (not just the raw-feed-content path) is safe -- it's a
+// no-op wherever no bare <iframe> survived, i.e. anything Readability already touched.
+private val youtubeIframeRegex = Regex(
+ """<iframe\b[^>]*\bsrc="[^"]*(?:youtube\.com|youtube-nocookie\.com)/embed/([a-zA-Z0-9_-]{11})[^"]*"[^>]*>.*?</iframe>""",
+ 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]
+ "<figure><a href=\"https://www.youtube.com/watch?v=$id\"><img src=\"https://i.ytimg.com/vi/$id/hq720.jpg\" alt=\"YouTube video\"></a><figcaption>▶ Watch on YouTube</figcaption></figure>"
+ }
+
+// 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
+// (<iframe class="truthsocial-embed" src="https://truthsocial.com/.../embed">, paired
+// with a truthsocial.com/embed.js loader script) rendered as a blank/broken frame in a
+// WhoWhatWhy article. Rather than special-case every platform individually, ANY iframe
+// still left after the YouTube-specific pass above (which already handles YouTube's case
+// better, with a real thumbnail) gets swapped for a plain link to the same src -- always
+// a safe, working fallback regardless of which platform it was.
+private val genericIframeRegex = Regex(
+ """<iframe\b[^>]*\bsrc="([^"]+)"[^>]*>.*?</iframe>""",
+ 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) "<p><a href=\"${escapeHtml(href)}\">View embedded content ↗</a></p>" else ""
+ }
+
private fun escapeHtml(text: String): String = text
.replace("&", "&amp;")
.replace("<", "&lt;")
@@ -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 <header> 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 <content:encoded> had zero <img> tags,
+ // its photo living only in a sibling <media:content> 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 <iframe> with
+ // no <img> of its own gets turned into a thumbnail <img> by replaceYouTubeIframes above,
+ // and checking the post-replacement string meant that synthesized video thumbnail
+ // counted as "the body already has an image," silently suppressing the article's real
+ // featured photo on any article that had both a YouTube embed and a separate hero image.
+ //
+ // isNullOrBlank(), not just != null: a feed's media:thumbnail/enclosure can have
+ // url="" (FeedParser only checks for null, not blank), which produced a broken
+ // <img src=""> instead of just showing nothing.
+ val heroHtml = if (!imageUrl.isNullOrBlank() && !bodyContent.contains("<img", ignoreCase = true)) {
+ "<img class=\"article-hero\" src=\"${escapeHtml(imageUrl)}\" alt=\"\">"
+ } else ""
+ return """
<html><head><meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: sans-serif; font-size: 18px; line-height: 1.5; padding: 16px;
background-color: #121212; color: #e1e1e1; }
h1.article-title { font-size: 1.5em; line-height: 1.3; margin: 0 0 0.6em 0; }
+ p.article-date { font-size: 0.85em; color: #9aa0a6; margin: -0.4em 0 1em 0; }
+ img.article-hero { border-radius: 8px; margin: 0 0 1em 0; }
a { color: #8ab4f8; }
img { max-width: 100%; height: auto; }
svg { max-width: 100%; }
@@ -143,5 +369,8 @@ fun wrapReaderHtml(bodyContent: String, title: String? = null): String = """
those alone -- just cap the ones that would otherwise size themselves. */
svg:not([width]):not([height]) { max-width: 32px; max-height: 32px; }
</style></head>
- <body>${if (!title.isNullOrBlank()) "<h1 class=\"article-title\">${escapeHtml(title)}</h1>" else ""}$bodyContent</body></html>
-""".trimIndent()
+ <body>${if (!title.isNullOrBlank()) "<h1 class=\"article-title\">${escapeHtml(title)}</h1>" else ""}${
+ if (publishedAt != null) "<p class=\"article-date\">${escapeHtml(absoluteDateText(publishedAt))}</p>" else ""
+ }$heroHtml$processedBody</body></html>
+ """.trimIndent()
+}