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. --- .../main/java/org/terst/feedreader/data/Daos.kt | 9 + .../org/terst/feedreader/ui/ArticleListScreen.kt | 23 +- .../java/org/terst/feedreader/ui/FeedListScreen.kt | 28 +-- .../org/terst/feedreader/ui/LinkReaderScreen.kt | 49 +++-- .../java/org/terst/feedreader/ui/ReaderScreen.kt | 15 +- .../java/org/terst/feedreader/ui/ReaderWebView.kt | 239 ++++++++++++++++++++- 6 files changed, 312 insertions(+), 51 deletions(-) (limited to 'app/src/main/java/org/terst/feedreader') 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) { + 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(null) } + var youtubeVideoId by remember(url) { mutableStateOf(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(null) } + var youtubeVideoId by remember(articleId) { mutableStateOf(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 """, + 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