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. --- .../main/java/org/terst/feedreader/ui/ImageZoom.kt | 259 +++++++++++++++++++++ .../org/terst/feedreader/ui/LinkReaderScreen.kt | 7 +- .../java/org/terst/feedreader/ui/ReaderScreen.kt | 5 + .../java/org/terst/feedreader/ui/ReaderWebView.kt | 24 +- .../java/org/terst/feedreader/ui/ImageZoomTest.kt | 178 ++++++++++++++ 5 files changed, 469 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/org/terst/feedreader/ui/ImageZoom.kt create mode 100644 app/src/test/java/org/terst/feedreader/ui/ImageZoomTest.kt (limited to 'app/src') diff --git a/app/src/main/java/org/terst/feedreader/ui/ImageZoom.kt b/app/src/main/java/org/terst/feedreader/ui/ImageZoom.kt new file mode 100644 index 0000000..262af87 --- /dev/null +++ b/app/src/main/java/org/terst/feedreader/ui/ImageZoom.kt @@ -0,0 +1,259 @@ +package org.terst.feedreader.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil.compose.AsyncImage +import kotlin.math.min + +// Article images live inside the reader's WebView, which runs with JavaScript off (see +// ReaderWebView) -- so there's no in-page script that could notice a tap on an and +// report it out. And WebView's own built-in zoom (setSupportZoom/builtInZoomControls) isn't +// an option either: pinching in the reader is already taken, deliberately, by the +// reader-font-size gesture, and page zoom would scale the whole article's layout rather than +// giving an image the full screen. +// +// So tap detection is done the one way that works with JS off: every image the reader renders +// is wrapped in a link to a scheme nothing can actually navigate to, which WebView then hands +// to shouldOverrideUrlLoading -- the same interception point that already routes ordinary +// links and YouTube thumbnails. The callback opens ImageZoomDialog instead of following it. +private const val IMAGE_ZOOM_SCHEME = "feedreader-image:" + +/** Fitted (1x) up to this; past ~6x an article image is nothing but its own pixels. */ +const val IMAGE_ZOOM_MAX_SCALE = 6f + +private const val IMAGE_ZOOM_DOUBLE_TAP_SCALE = 3f + +// An un-zoomed image has nothing to pan to, so a drag on one is read as "put this away" +// instead. Far enough that an ordinary sloppy tap can't trigger it by accident. +private val SWIPE_DISMISS_DISTANCE = 120.dp + +// Matches an opening , a closing , or an -- in document order, so a single pass +// can tell which images are already inside a link. `, +//
etc., since \b requires a non-word character right after the "a". +private val anchorOrImageTagRegex = Regex("""]*>||]*>""", RegexOption.IGNORE_CASE) + +private val imgSrcRegex = Regex("""\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)')""", RegexOption.IGNORE_CASE) + +private val imageFileExtensions = listOf(".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".bmp", ".heic", ".heif") + +/** + * Wraps every article image that isn't already inside a link in a zoom link, so tapping it + * reaches [zoomableImageTarget] via the reader WebView's shouldOverrideUrlLoading. + * + * Images that are already inside a link are left exactly as they were -- their link is doing + * something already (ReaderExtractor's synthesized YouTube thumbnail is precisely this shape, + * and wrapping it would swallow the tap that plays the video). Those are still zoomable when + * the link points at an image file, which [zoomableImageTarget] handles on the other side. + */ +fun linkifyImagesForZoom(html: String): String = buildString { + var copiedUpTo = 0 + var anchorDepth = 0 + for (tag in anchorOrImageTagRegex.findAll(html)) { + val text = tag.value + when { + text.startsWith(" if (anchorDepth > 0) anchorDepth-- + text.startsWith(" anchorDepth++ + anchorDepth == 0 -> { + val src = imgSrcRegex.find(text)?.let { it.groupValues[1].ifEmpty { it.groupValues[2] } } + val href = src?.let { zoomHrefForSrc(it) } + if (href != null) { + append(html, copiedUpTo, tag.range.first) + append("").append(text).append("") + copiedUpTo = tag.range.last + 1 + } + } + } + } + append(html, copiedUpTo, html.length) +} + +private fun zoomHrefForSrc(src: String): String? { + val trimmed = src.trim() + val absolute = when { + // Protocol-relative (//host/path) is common in feed markup and resolves fine in the + // page itself, but our scheme-prefixed href has to carry a complete URL on its own. + trimmed.startsWith("//") -> "https:$trimmed" + trimmed.startsWith("http://", ignoreCase = true) || trimmed.startsWith("https://", ignoreCase = true) -> trimmed + // Anything else (site-relative, data:, javascript:) is skipped rather than guessed at: + // a relative src resolves against the WebView's base URL, which a custom-scheme href + // can't reproduce, so wrapping it would only build a dead link. + else -> null + } ?: return null + // The src came out of a quoted attribute and goes straight back into one, already escaped + // as the source wrote it. A raw quote or angle bracket could only arrive via a + // single-quoted attribute, and would break out of the href -- drop those instead. + return if (absolute.any { it == '"' || it == '<' || it == '>' }) null else IMAGE_ZOOM_SCHEME + absolute +} + +/** + * The image a tapped URL should open full-screen, or null if it isn't an image at all. + * + * Covers both halves of the problem: links [linkifyImagesForZoom] built, and links the article + * itself already had that point straight at an image file (WordPress's default "link to media + * file" wraps article images in exactly that, which used to send a tap off to LinkReaderScreen + * to try and run Readability over a JPEG). + */ +fun zoomableImageTarget(url: String): String? = when { + url.startsWith(IMAGE_ZOOM_SCHEME, ignoreCase = true) -> + url.substring(IMAGE_ZOOM_SCHEME.length).takeIf { it.isNotBlank() } + looksLikeImageUrl(url) -> url + else -> null +} + +// Extension-sniffing, not a content-type check -- there's no response to inspect at the point +// a link is tapped. SVG is deliberately not in the list: Coil's default decoders can't render +// one, so a viewer opened on it would just show an empty black screen. +private fun looksLikeImageUrl(url: String): Boolean { + val path = url.substringBefore('#').substringBefore('?').lowercase() + return imageFileExtensions.any { path.endsWith(it) } +} + +/** + * The URL to show the user for a link -- the zoom scheme is an internal detail, so a + * long-press on a zoom-wrapped image offers actions on the real image URL. + */ +fun withoutImageZoomScheme(url: String): String = + if (url.startsWith(IMAGE_ZOOM_SCHEME, ignoreCase = true)) url.substring(IMAGE_ZOOM_SCHEME.length) else url + +/** Applies a pinch to the current zoom level, clamped to fitted-size..[IMAGE_ZOOM_MAX_SCALE]. */ +fun imageZoomScale(currentScale: Float, gestureZoom: Float): Float = + (currentScale * gestureZoom).coerceIn(1f, IMAGE_ZOOM_MAX_SCALE) + +/** Double-tap toggles: zoomed at all means "put it back," otherwise zoom in. */ +fun imageZoomDoubleTapScale(currentScale: Float): Float = + if (currentScale > 1f) 1f else IMAGE_ZOOM_DOUBLE_TAP_SCALE + +/** + * Keeps a pan within the part of the scaled image that's actually off-screen, so the image + * can never be dragged away from under the viewport and leave empty space. At fitted size + * nothing hangs off either edge, so there's nowhere to pan and the offset pins to 0. + */ +fun clampImagePanOffset(offset: Float, viewportSize: Float, contentSize: Float, scale: Float): Float { + val overhang = ((contentSize * scale - viewportSize) / 2f).coerceAtLeast(0f) + return offset.coerceIn(-overhang, overhang) +} + +/** + * Where the image actually ends up inside the viewport under ContentScale.Fit -- scaled up or + * down to touch the nearer pair of edges, letterboxed on the other axis. [clampImagePanOffset] + * needs this rather than the viewport: bounding a pan by the viewport would let a wide photo be + * dragged up into the empty black bar above it. + * + * Falls back to the viewport's own size while the image's dimensions are still unknown -- Coil + * can only report them once the image has decoded, and a pan has to be bounded by something in + * the meantime. + */ +fun fittedImageSize( + viewportWidth: Float, + viewportHeight: Float, + imageWidth: Float, + imageHeight: Float +): Pair { + val known = { size: Float -> size.isFinite() && size > 0f } + if (!known(imageWidth) || !known(imageHeight)) return viewportWidth to viewportHeight + val fit = min(viewportWidth / imageWidth, viewportHeight / imageHeight) + return imageWidth * fit to imageHeight * fit +} + +/** + * Full-screen viewer for a tapped article image: pinch to zoom, drag to pan while zoomed, + * double-tap to toggle between fitted and zoomed, and dismiss with the system back gesture, + * the close button, or a swipe down while un-zoomed. + * + * Coil's AsyncImage, the same loader the article list's thumbnails already use, so an image + * the user just scrolled past is usually served straight from its cache. The zoom itself is a + * graphicsLayer transform rather than a re-decode, which keeps a pinch smooth and costs + * nothing until it's used. + */ +@Composable +fun ImageZoomDialog(imageUrl: String, onDismiss: () -> Unit) { + // usePlatformDefaultWidth would cap this at the platform's narrow AlertDialog width -- + // same reason YouTubePlayerDialog opts out. A photo viewer wants the whole screen. + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { + var scale by remember { mutableStateOf(1f) } + var offsetX by remember { mutableStateOf(0f) } + var offsetY by remember { mutableStateOf(0f) } + var viewport by remember { mutableStateOf(IntSize.Zero) } + var dismissDragY by remember { mutableStateOf(0f) } + // The image's own decoded dimensions, reported by Coil once it has one to report. + // Panning is bounded by where the image actually is, not by the whole viewport -- + // ContentScale.Fit letterboxes anything that isn't exactly the screen's aspect ratio. + var intrinsic by remember(imageUrl) { mutableStateOf(0f to 0f) } + val fitted = fittedImageSize(viewport.width.toFloat(), viewport.height.toFloat(), intrinsic.first, intrinsic.second) + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .onSizeChanged { viewport = it } + .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { + scale = imageZoomDoubleTapScale(scale) + offsetX = clampImagePanOffset(offsetX, viewport.width.toFloat(), fitted.first, scale) + offsetY = clampImagePanOffset(offsetY, viewport.height.toFloat(), fitted.second, scale) + } + ) + } + .pointerInput(Unit) { + val dismissDistancePx = SWIPE_DISMISS_DISTANCE.toPx() + detectTransformGestures { _, pan, zoom, _ -> + val newScale = imageZoomScale(scale, zoom) + if (newScale > 1f) { + // Zoomed in: a drag is a pan, and can't also be a dismiss -- otherwise + // panning down to the bottom of a tall image would close the viewer. + dismissDragY = 0f + } else { + dismissDragY += pan.y + if (dismissDragY > dismissDistancePx) onDismiss() + } + scale = newScale + offsetX = clampImagePanOffset(offsetX + pan.x, viewport.width.toFloat(), fitted.first, newScale) + offsetY = clampImagePanOffset(offsetY + pan.y, viewport.height.toFloat(), fitted.second, newScale) + } + } + ) { + AsyncImage( + model = imageUrl, + contentDescription = null, + contentScale = ContentScale.Fit, + onSuccess = { intrinsic = it.painter.intrinsicSize.width to it.painter.intrinsicSize.height }, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = scale + scaleY = scale + translationX = offsetX + translationY = offsetY + } + ) + IconButton(onClick = onDismiss, modifier = Modifier.align(Alignment.TopEnd).padding(8.dp)) { + Icon(Icons.Default.Close, contentDescription = "Close", tint = Color.White) + } + } + } +} 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 d71b020..b8956f2 100644 --- a/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt @@ -82,6 +82,7 @@ fun LinkReaderScreen(url: String, onBack: () -> Unit, onOpenLink: (String) -> Un var moreMenuExpanded by remember(url) { mutableStateOf(false) } var longPressedLink by remember(url) { mutableStateOf(null) } var youtubeVideoId by remember(url) { mutableStateOf(null) } + var zoomedImageUrl by remember(url) { mutableStateOf(null) } LaunchedEffect(url) { val existing = db.articleDao().getById(articleId) @@ -237,7 +238,8 @@ fun LinkReaderScreen(url: String, onBack: () -> Unit, onOpenLink: (String) -> Un ReaderWebViewCallbacks( onOpenLink = onOpenLink, onLongPressLink = { longPressedLink = it }, - onYouTubeVideo = { youtubeVideoId = it } + onYouTubeVideo = { youtubeVideoId = it }, + onImageTap = { zoomedImageUrl = it } ) ).apply { loadDataWithBaseURL(url, wrapReaderHtml(currentExtractedContent, extractedTitle), "text/html", "UTF-8", null) @@ -289,6 +291,9 @@ fun LinkReaderScreen(url: String, onBack: () -> Unit, onOpenLink: (String) -> Un youtubeVideoId?.let { videoId -> YouTubePlayerDialog(videoId = videoId, onDismiss = { youtubeVideoId = null }) } + zoomedImageUrl?.let { imageUrl -> + ImageZoomDialog(imageUrl = imageUrl, onDismiss = { zoomedImageUrl = 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 e2b8aa9..165d421 100644 --- a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt +++ b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt @@ -200,6 +200,7 @@ private fun ReaderContent( var bugReportDescription by remember(articleId) { mutableStateOf("") } var longPressedLink by remember(articleId) { mutableStateOf(null) } var youtubeVideoId by remember(articleId) { mutableStateOf(null) } + var zoomedImageUrl by remember(articleId) { mutableStateOf(null) } LaunchedEffect(articleId) { val loaded = db.articleDao().getById(articleId) ?: return@LaunchedEffect @@ -489,6 +490,9 @@ private fun ReaderContent( youtubeVideoId?.let { videoId -> YouTubePlayerDialog(videoId = videoId, onDismiss = { youtubeVideoId = null }) } + zoomedImageUrl?.let { imageUrl -> + ImageZoomDialog(imageUrl = imageUrl, onDismiss = { zoomedImageUrl = null }) + } longPressedLink?.let { url -> LinkActionsDialog( url = url, @@ -545,6 +549,7 @@ private fun ReaderContent( onOpenLink = onOpenLink, onLongPressLink = { longPressedLink = it }, onYouTubeVideo = { youtubeVideoId = it }, + onImageTap = { zoomedImageUrl = it }, onScrollChanged = { scrollY -> ReaderScrollPositions.set(articleId, scrollY) }, // The page's real content height isn't known until layout finishes // -- scrolling to a saved offset any earlier would just clamp back 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() } diff --git a/app/src/test/java/org/terst/feedreader/ui/ImageZoomTest.kt b/app/src/test/java/org/terst/feedreader/ui/ImageZoomTest.kt new file mode 100644 index 0000000..81ecc33 --- /dev/null +++ b/app/src/test/java/org/terst/feedreader/ui/ImageZoomTest.kt @@ -0,0 +1,178 @@ +package org.terst.feedreader.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ImageZoomTest { + + @Test + fun `a bare image is wrapped in a zoom link pointing at its own source`() { + val html = linkifyImagesForZoom("""

hi

x""") + assertEquals( + """

hi

x""", + html + ) + } + + @Test + fun `an image already inside a link is left alone`() { + // The YouTube thumbnail ReaderExtractor synthesizes is exactly this shape -- wrapping it + // again would swallow the tap that's supposed to play the video. + val original = + """""" + assertEquals(original, linkifyImagesForZoom(original)) + } + + @Test + fun `an image after a closed link is still wrapped`() { + val html = linkifyImagesForZoom("""t""") + assertTrue(html.endsWith("""""")) + } + + @Test + fun `a protocol-relative image source becomes an https zoom target`() { + val html = linkifyImagesForZoom("""""") + assertEquals("""""", html) + } + + @Test + fun `a relative image source is left alone`() { + // Relative srcs resolve against the WebView's base URL, which a custom-scheme href + // can't reproduce -- better to leave the image un-zoomable than to build a dead link. + val original = """""" + assertEquals(original, linkifyImagesForZoom(original)) + } + + @Test + fun `a data uri image is left alone`() { + val original = """""" + assertEquals(original, linkifyImagesForZoom(original)) + } + + @Test + fun `wrapping is not confused by tags that merely start with the letter a`() { + val html = linkifyImagesForZoom("""y""") + assertTrue(html.contains("""""")) + } + + @Test + fun `a zoom-scheme url resolves back to the image it wraps`() { + assertEquals("https://ex.com/a.jpg", zoomableImageTarget("feedreader-image:https://ex.com/a.jpg")) + } + + @Test + fun `a plain link straight to an image file is a zoom target too`() { + // WordPress's default "link to media file" wraps article images in exactly this -- + // the image is inside an , so it never gets a zoom-scheme href of its own. + assertEquals("https://ex.com/photo.JPEG?w=900", zoomableImageTarget("https://ex.com/photo.JPEG?w=900")) + } + + @Test + fun `an ordinary article link is not a zoom target`() { + assertNull(zoomableImageTarget("https://ex.com/2026/09/some-article")) + } + + @Test + fun `an svg link is not a zoom target`() { + // Coil's default decoders can't render SVG, so a viewer opened on one shows nothing. + assertNull(zoomableImageTarget("https://ex.com/icon.svg")) + } + + @Test + fun `reader html makes an article's own images zoomable`() { + val html = wrapReaderHtml("""

x

""") + assertTrue(html.contains("""
""")) + } + + @Test + fun `reader html makes the synthesized hero image zoomable too`() { + val html = wrapReaderHtml("

no image of its own

", imageUrl = "https://ex.com/hero.jpg") + assertTrue(html.contains("""""")) + assertTrue(html.contains("""class="article-hero"""")) + } + + @Test + fun `reader html leaves a youtube thumbnail tapping through to the video`() { + // The thumbnail is synthesized from an """) + assertTrue(html.contains("""""")) + assertTrue(!html.contains("feedreader-image:https://i.ytimg.com")) + } + + @Test + fun `long-pressing a zoom-wrapped image offers the real image url, not the internal scheme`() { + assertEquals("https://ex.com/a.jpg", withoutImageZoomScheme("feedreader-image:https://ex.com/a.jpg")) + assertEquals("https://ex.com/page", withoutImageZoomScheme("https://ex.com/page")) + } + + @Test + fun `pinching out scales the image up`() { + assertEquals(2f, imageZoomScale(1f, 2f), 0.001f) + } + + @Test + fun `the image never zooms out past its fitted size`() { + assertEquals(1f, imageZoomScale(1.2f, 0.1f), 0.001f) + } + + @Test + fun `the image never zooms in past the maximum scale`() { + assertEquals(IMAGE_ZOOM_MAX_SCALE, imageZoomScale(4f, 10f), 0.001f) + } + + @Test + fun `double-tapping an unzoomed image zooms it in`() { + assertTrue(imageZoomDoubleTapScale(1f) > 1f) + } + + @Test + fun `double-tapping a zoomed image returns it to its fitted size`() { + assertEquals(1f, imageZoomDoubleTapScale(imageZoomDoubleTapScale(1f)), 0.001f) + } + + @Test + fun `an unzoomed image cannot be panned`() { + assertEquals(0f, clampImagePanOffset(400f, viewportSize = 1000f, contentSize = 1000f, scale = 1f), 0.001f) + } + + @Test + fun `a zoomed image pans up to the edge of its scaled self`() { + // At 2x over a 1000px viewport the image is 2000px wide, so 500px of it hangs off + // each side -- that's exactly how far it may be dragged before showing empty space. + assertEquals(500f, clampImagePanOffset(9000f, 1000f, 1000f, 2f), 0.001f) + assertEquals(-500f, clampImagePanOffset(-9000f, 1000f, 1000f, 2f), 0.001f) + assertEquals(120f, clampImagePanOffset(120f, 1000f, 1000f, 2f), 0.001f) + } + + @Test + fun `panning is bounded by the image itself, not by the viewport it sits in`() { + // A wide photo letterboxed into a square viewport: at 2x it's 1000x1000 again, so it + // still fills the viewport exactly and there's nothing off-screen to pan to + // vertically -- bounding by the viewport instead would let it be dragged into the + // black bars above and below it. + assertEquals(0f, clampImagePanOffset(400f, 1000f, 500f, 2f), 0.001f) + // ...but at 4x it's 2000px tall over a 1000px viewport, so 500px hangs off each edge. + assertEquals(500f, clampImagePanOffset(9000f, 1000f, 500f, 4f), 0.001f) + } + + @Test + fun `a wide image is fitted to the viewport's width, leaving bars above and below`() { + assertEquals(1000f to 500f, fittedImageSize(1000f, 1000f, 2000f, 1000f)) + } + + @Test + fun `a tall image is fitted to the viewport's height`() { + assertEquals(500f to 1000f, fittedImageSize(1000f, 1000f, 1000f, 2000f)) + } + + @Test + fun `an image whose size isn't known yet is assumed to fill the viewport`() { + // Coil only reports intrinsic size once the image has actually decoded; until then + // panning has to be bounded by something, and the viewport is the safe guess. + assertEquals(1000f to 800f, fittedImageSize(1000f, 800f, 0f, 0f)) + assertEquals(1000f to 800f, fittedImageSize(1000f, 800f, Float.NaN, Float.NaN)) + } +} -- cgit v1.2.3