From 05f3d13b11a560220771e8d351b77173532e0be0 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Fri, 18 Sep 2026 05:11:25 +0000 Subject: Tap an article image to open it full-screen, with pinch/double-tap zoom Article images could be looked at but never enlarged -- a photo or a chart in a feed was stuck at whatever size the reader column gave it. Tapping one now opens a full-screen viewer: pinch to zoom (up to 6x), double-tap to toggle between fitted and 3x, drag to pan while zoomed, and dismiss with back, the close button, or a swipe down. Detecting the tap is the whole difficulty. The reader is a WebView, and its JavaScript is off deliberately (untrusted article content), so no in-page script can notice a tap on an and report it out. WebView's own built-in zoom isn't the answer either: pinching in the reader is already taken, on purpose, by the reader-font-size gesture, and page zoom scales the article's whole layout rather than giving an image the screen. So wrapReaderHtml now wraps every image that isn't already inside a link in a link to a scheme nothing can navigate to (feedreader-image:), which WebView hands to shouldOverrideUrlLoading -- the interception point that already routes ordinary links and YouTube thumbnails. The new onImageTap callback opens ImageZoomDialog instead of following it. Images that already sit inside a link are left alone: ReaderExtractor's synthesized YouTube thumbnail is exactly that shape, and re-wrapping it would swallow the tap that plays the video. Those stay zoomable from the other side -- a link pointing straight at an image file is also treated as a zoom target, which additionally fixes WordPress's default "link to media file" markup sending a tap off to LinkReaderScreen to run Readability over a JPEG. The long-press-link handler strips the scheme back off, so the actions menu still offers the real image URL. The viewer is Compose + Coil (AsyncImage), matching what the article list's thumbnails already use, so a just-scrolled-past image usually comes straight from Coil's cache; zoom is a graphicsLayer transform, not a re-decode. Panning is bounded by where the image actually is rather than by the viewport -- ContentScale.Fit letterboxes anything that isn't the screen's aspect ratio, and bounding by the viewport would let a wide photo be dragged up into its own black bar. Coil reports the intrinsic size once decoded; until then the viewport is the fallback bound. TDD: the HTML rewriting (linkifyImagesForZoom), the tap-target decision (zoomableImageTarget), and the zoom/pan math (imageZoomScale, imageZoomDoubleTapScale, clampImagePanOffset, fittedImageSize) were all written test-first against stubs, red on assertion mismatches, green on the real implementations -- 26 new cases in ImageZoomTest, including three over wrapReaderHtml itself to pin the ordering that keeps the YouTube thumbnail's link intact. Gesture wiring and the dialog itself are Compose UI, untestable here for the same reason the rest of ui/ is. Coverage is unchanged: ui/ is excluded from the Jacoco metric by design. --- .../java/org/terst/feedreader/ui/ReaderWebView.kt | 24 +++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) (limited to 'app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt') diff --git a/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt b/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt index 039255f..9a11dd3 100644 --- a/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt +++ b/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt @@ -79,6 +79,11 @@ private fun extractYouTubeVideoId(uri: Uri): String? { * back to the external-open behavior when a confident video ID can't be extracted * (a playlist/channel link, say). * + * onImageTap fires for a tap on an article image (see ImageZoom.kt for how a tap on an + * is detected at all with JS off) with the image's own URL -- callers show a full-screen + * ImageZoomDialog. Also fires for a link pointing straight at an image file, which used to + * send LinkReaderScreen off to run Readability over a JPEG. + * * Pinch-to-zoom adjusts the persisted reader font size, not WebView's own page zoom * (never enabled here -- setSupportZoom/builtInZoomControls are left at their defaults, * which is effectively off). A real page zoom would scale layout and images along with @@ -109,6 +114,7 @@ data class ReaderWebViewCallbacks( val onOpenLink: (String) -> Unit, val onLongPressLink: (String) -> Unit = {}, val onYouTubeVideo: (String) -> Unit = {}, + val onImageTap: (String) -> Unit = {}, val onScrollChanged: (Int) -> Unit = {}, val onPageFinished: (WebView) -> Unit = {}, val currentFontSizePx: () -> Int = { DEFAULT_READER_FONT_SIZE_PX }, @@ -175,7 +181,13 @@ fun newReaderWebView(context: Context, callbacks: ReaderWebViewCallbacks): WebVi // worked here anyway. if (!request.isForMainFrame) return true val url = request.url - if (isYouTubeWatchUrl(url)) { + // Checked before anything else: an image tap is never a navigation we'd want to + // follow. Covers both the zoom links wrapReaderHtml adds to the article's own + // images and a link the article itself already pointed straight at an image file. + val imageUrl = zoomableImageTarget(url.toString()) + if (imageUrl != null) { + callbacks.onImageTap(imageUrl) + } else if (isYouTubeWatchUrl(url)) { val videoId = extractYouTubeVideoId(url) if (videoId != null) { callbacks.onYouTubeVideo(videoId) @@ -195,7 +207,10 @@ fun newReaderWebView(context: Context, callbacks: ReaderWebViewCallbacks): WebVi else -> null } if (url != null) { - callbacks.onLongPressLink(url) + // Every article image now sits inside a zoom link, so a long-press on one + // reports that link's internal scheme -- strip it back to the image's own URL + // so the actions menu shows, and acts on, something real. + callbacks.onLongPressLink(withoutImageZoomScheme(url)) true } else { false @@ -414,6 +429,9 @@ fun wrapReaderHtml( fontSizePx: Int = DEFAULT_READER_FONT_SIZE_PX ): String { val processedBody = replaceGenericIframes(replaceYouTubeIframes(bodyContent)) + // NB: linkifyImagesForZoom runs at the very end (below, over hero + body together), after + // the iframe passes -- the YouTube thumbnail one of them synthesizes is an image inside a + // link, and the zoom pass has to see it as one to leave its tap-to-play link alone. // 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 @@ -464,6 +482,6 @@ fun wrapReaderHtml( ${if (!title.isNullOrBlank()) "

${escapeHtml(title)}

" else ""}${ if (publishedAt != null) "

${escapeHtml(absoluteDateText(publishedAt))}

" else "" - }$heroHtml$processedBody + }${linkifyImagesForZoom(heroHtml + processedBody)} """.trimIndent() } -- cgit v1.2.3