summaryrefslogtreecommitdiff
path: root/app/src/main/java/org/terst
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java/org/terst')
-rw-r--r--app/src/main/java/org/terst/feedreader/data/Daos.kt9
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt23
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt28
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt49
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt15
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt239
6 files changed, 312 insertions, 51 deletions
diff --git a/app/src/main/java/org/terst/feedreader/data/Daos.kt b/app/src/main/java/org/terst/feedreader/data/Daos.kt
index 1f442e9..8a5f50e 100644
--- a/app/src/main/java/org/terst/feedreader/data/Daos.kt
+++ b/app/src/main/java/org/terst/feedreader/data/Daos.kt
@@ -149,6 +149,15 @@ interface ArticleDao {
suspend fun delete(article: ArticleEntity)
}
+// A single markReadBatch(ids) call builds one query with one bound parameter per id --
+// "mark read from here" can realistically span hundreds of rows in a long-unread feed,
+// and SQLite has a hard per-statement limit on bound variables (historically 999 on many
+// Android SQLite builds). Chunking well under any realistic limit means this can't throw
+// "too many SQL variables" and crash on an otherwise routine bulk mark-read tap.
+suspend fun ArticleDao.markReadBatchChunked(ids: List<String>) {
+ ids.chunked(500).forEach { chunk -> markReadBatch(chunk) }
+}
+
@Dao
interface FilterRuleDao {
@Query("SELECT * FROM filter_rules ORDER BY createdAt DESC")
diff --git a/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt b/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt
index 6f2db12..cbad3e2 100644
--- a/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/ArticleListScreen.kt
@@ -14,7 +14,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.List
-import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -23,7 +22,6 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -46,6 +44,7 @@ import org.terst.feedreader.data.AppDatabase
import org.terst.feedreader.data.ArticleListItem
import org.terst.feedreader.data.FilterResult
import org.terst.feedreader.data.FilterRuleEngine
+import org.terst.feedreader.data.markReadBatchChunked
import java.text.DateFormat
import java.util.Date
@@ -102,19 +101,15 @@ fun ArticleListScreen(
pendingMarkReadFrom?.let { from ->
val index = visibleArticles.indexOfFirst { it.first.id == from.id }
val toMark = if (index >= 0) visibleArticles.drop(index).map { it.first }.filter { !it.isRead } else emptyList()
- AlertDialog(
- onDismissRequest = { pendingMarkReadFrom = null },
- title = { Text("Mark read from here?") },
- text = { Text("Marks ${toMark.size} article(s) as read.") },
- confirmButton = {
- TextButton(onClick = {
- scope.launch { db.articleDao().markReadBatch(toMark.map { it.id }) }
- pendingMarkReadFrom = null
- }) { Text("Mark read") }
+ ConfirmDialog(
+ title = "Mark read from here?",
+ text = "Marks ${toMark.size} article(s) as read.",
+ confirmLabel = "Mark read",
+ onConfirm = {
+ scope.launch { db.articleDao().markReadBatchChunked(toMark.map { it.id }) }
+ pendingMarkReadFrom = null
},
- dismissButton = {
- TextButton(onClick = { pendingMarkReadFrom = null }) { Text("Cancel") }
- }
+ onDismiss = { pendingMarkReadFrom = null }
)
}
diff --git a/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt b/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt
index d87566b..90bfd7e 100644
--- a/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/FeedListScreen.kt
@@ -19,7 +19,6 @@ import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.outlined.Article
-import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -33,7 +32,6 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -87,22 +85,18 @@ fun FeedListScreen(
val snackbarHostState = remember { SnackbarHostState() }
pendingDelete?.let { feed ->
- AlertDialog(
- onDismissRequest = { pendingDelete = null },
- title = { Text("Unsubscribe from ${feed.title.ifBlank { feed.id }}?") },
- text = { Text("Removes its articles. Anything you've saved is kept.") },
- confirmButton = {
- TextButton(onClick = {
- scope.launch {
- db.articleDao().deleteUnsavedByFeed(feed.id)
- db.feedDao().delete(feed.id)
- }
- pendingDelete = null
- }) { Text("Unsubscribe") }
+ ConfirmDialog(
+ title = "Unsubscribe from ${feed.title.ifBlank { feed.id }}?",
+ text = "Removes its articles. Anything you've saved is kept.",
+ confirmLabel = "Unsubscribe",
+ onConfirm = {
+ scope.launch {
+ db.articleDao().deleteUnsavedByFeed(feed.id)
+ db.feedDao().delete(feed.id)
+ }
+ pendingDelete = null
},
- dismissButton = {
- TextButton(onClick = { pendingDelete = null }) { Text("Cancel") }
- }
+ onDismiss = { pendingDelete = null }
)
}
diff --git a/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt b/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt
index 3451833..0f7540d 100644
--- a/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt
@@ -39,6 +39,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.viewinterop.AndroidView
import kotlinx.coroutines.launch
@@ -80,6 +81,7 @@ fun LinkReaderScreen(url: String, onBack: () -> Unit, onOpenLink: (String) -> Un
var isReextracting by remember(url) { mutableStateOf(false) }
var moreMenuExpanded by remember(url) { mutableStateOf(false) }
var longPressedLink by remember(url) { mutableStateOf<String?>(null) }
+ var youtubeVideoId by remember(url) { mutableStateOf<String?>(null) }
LaunchedEffect(url) {
val existing = db.articleDao().getById(articleId)
@@ -230,7 +232,12 @@ fun LinkReaderScreen(url: String, onBack: () -> Unit, onOpenLink: (String) -> Un
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
- newReaderWebView(ctx, onOpenLink, onLongPressLink = { longPressedLink = it }).apply {
+ newReaderWebView(
+ ctx,
+ onOpenLink,
+ onLongPressLink = { longPressedLink = it },
+ onYouTubeVideo = { youtubeVideoId = it }
+ ).apply {
loadDataWithBaseURL(url, wrapReaderHtml(currentExtractedContent, extractedTitle), "text/html", "UTF-8", null)
loadedContent.value = currentExtractedContent
}
@@ -247,21 +254,39 @@ fun LinkReaderScreen(url: String, onBack: () -> Unit, onOpenLink: (String) -> Un
// Reader mode off, or extraction failed -- fall back to the live page
// itself (JS on) rather than leaving the screen blank.
!isReaderMode || extractionFailed -> {
- AndroidView(
- modifier = Modifier.fillMaxSize(),
- factory = { ctx ->
- WebView(ctx).apply {
- settings.javaScriptEnabled = true
- settings.domStorageEnabled = true
- loadUrl(url)
- }
- },
- onRelease = { webView -> webView.destroy() }
- )
+ if (!url.startsWith("http://", ignoreCase = true) && !url.startsWith("https://", ignoreCase = true)) {
+ // `url` isn't necessarily a link the user themselves tapped: it can
+ // also be whatever a WebView's shouldOverrideUrlLoading reported for
+ // ANY navigation attempt inside an article (see ReaderWebView's
+ // onOpenLink), including one triggered by the article's own HTML
+ // linking to a non-http scheme (intent://, tel:, etc.). Refusing to
+ // load anything but http(s) here means this JS-enabled WebView can't
+ // become a sink for whatever scheme a crafted article handed it.
+ Text(
+ "Can't open this link: $url",
+ modifier = Modifier.fillMaxSize().padding(16.dp),
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ } else {
+ AndroidView(
+ modifier = Modifier.fillMaxSize(),
+ factory = { ctx ->
+ WebView(ctx).apply {
+ settings.javaScriptEnabled = true
+ settings.domStorageEnabled = true
+ loadUrl(url)
+ }
+ },
+ onRelease = { webView -> webView.destroy() }
+ )
+ }
}
else -> CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
+ youtubeVideoId?.let { videoId ->
+ YouTubePlayerDialog(videoId = videoId, onDismiss = { youtubeVideoId = null })
+ }
longPressedLink?.let { linkUrl ->
LinkActionsDialog(
url = linkUrl,
diff --git a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
index 1fe198e..b586fbc 100644
--- a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
@@ -188,6 +188,7 @@ private fun ReaderContent(
var showBugReportDialog by remember(articleId) { mutableStateOf(false) }
var bugReportDescription by remember(articleId) { mutableStateOf("") }
var longPressedLink by remember(articleId) { mutableStateOf<String?>(null) }
+ var youtubeVideoId by remember(articleId) { mutableStateOf<String?>(null) }
LaunchedEffect(articleId) {
val loaded = db.articleDao().getById(articleId) ?: return@LaunchedEffect
@@ -470,6 +471,9 @@ private fun ReaderContent(
}
)
}
+ youtubeVideoId?.let { videoId ->
+ YouTubePlayerDialog(videoId = videoId, onDismiss = { youtubeVideoId = null })
+ }
longPressedLink?.let { url ->
LinkActionsDialog(
url = url,
@@ -512,14 +516,19 @@ private fun ReaderContent(
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
- newReaderWebView(ctx, onOpenLink, onLongPressLink = { longPressedLink = it }).apply {
- loadDataWithBaseURL(baseUrl, wrapReaderHtml(content, article?.title), "text/html", "UTF-8", null)
+ newReaderWebView(
+ ctx,
+ onOpenLink,
+ onLongPressLink = { longPressedLink = it },
+ onYouTubeVideo = { youtubeVideoId = it }
+ ).apply {
+ loadDataWithBaseURL(baseUrl, wrapReaderHtml(content, article?.title, article?.publishedAt, article?.imageUrl), "text/html", "UTF-8", null)
loadedContent.value = content
}
},
update = { webView ->
if (loadedContent.value != content) {
- webView.loadDataWithBaseURL(baseUrl, wrapReaderHtml(content, article?.title), "text/html", "UTF-8", null)
+ webView.loadDataWithBaseURL(baseUrl, wrapReaderHtml(content, article?.title, article?.publishedAt, article?.imageUrl), "text/html", "UTF-8", null)
loadedContent.value = content
}
},
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()
+}