diff options
Diffstat (limited to 'app/src/main')
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("&", "&") .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 <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() +} |
