diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-09-18 05:11:25 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-09-18 05:12:31 +0000 |
| commit | 05f3d13b11a560220771e8d351b77173532e0be0 (patch) | |
| tree | d1aa536a7665d24644d1f0bd999e448e54a29a7d /app/src/test/java | |
| parent | b5a96ad1d0a412c21159d6265e451f7c3aefc796 (diff) | |
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 <img> 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:<url>), 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.
Diffstat (limited to 'app/src/test/java')
| -rw-r--r-- | app/src/test/java/org/terst/feedreader/ui/ImageZoomTest.kt | 178 |
1 files changed, 178 insertions, 0 deletions
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("""<p>hi</p><img src="https://ex.com/a.jpg" alt="x">""") + assertEquals( + """<p>hi</p><a href="feedreader-image:https://ex.com/a.jpg"><img src="https://ex.com/a.jpg" alt="x"></a>""", + 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 = + """<a href="https://www.youtube.com/watch?v=abcdefghijk"><img src="https://i.ytimg.com/vi/x/hq720.jpg"></a>""" + assertEquals(original, linkifyImagesForZoom(original)) + } + + @Test + fun `an image after a closed link is still wrapped`() { + val html = linkifyImagesForZoom("""<a href="https://ex.com/">t</a><img src="https://ex.com/a.png">""") + assertTrue(html.endsWith("""<a href="feedreader-image:https://ex.com/a.png"><img src="https://ex.com/a.png"></a>""")) + } + + @Test + fun `a protocol-relative image source becomes an https zoom target`() { + val html = linkifyImagesForZoom("""<img src="//ex.com/a.jpg">""") + assertEquals("""<a href="feedreader-image:https://ex.com/a.jpg"><img src="//ex.com/a.jpg"></a>""", 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 = """<img src="/local/a.jpg">""" + assertEquals(original, linkifyImagesForZoom(original)) + } + + @Test + fun `a data uri image is left alone`() { + val original = """<img src="data:image/png;base64,AAAA">""" + assertEquals(original, linkifyImagesForZoom(original)) + } + + @Test + fun `wrapping is not confused by tags that merely start with the letter a`() { + val html = linkifyImagesForZoom("""<abbr title="x">y</abbr><img src="https://ex.com/a.jpg">""") + assertTrue(html.contains("""<a href="feedreader-image:https://ex.com/a.jpg">""")) + } + + @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 <a>, 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("""<p>x</p><img src="https://ex.com/a.jpg">""") + assertTrue(html.contains("""<a href="feedreader-image:https://ex.com/a.jpg"><img src="https://ex.com/a.jpg"></a>""")) + } + + @Test + fun `reader html makes the synthesized hero image zoomable too`() { + val html = wrapReaderHtml("<p>no image of its own</p>", imageUrl = "https://ex.com/hero.jpg") + assertTrue(html.contains("""<a href="feedreader-image:https://ex.com/hero.jpg">""")) + 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 <iframe> during wrapping, after which it's an + // image inside a link -- the zoom pass has to run late enough to see it as one. + val html = wrapReaderHtml("""<iframe src="https://www.youtube.com/embed/abcdefghijk"></iframe>""") + assertTrue(html.contains("""<a href="https://www.youtube.com/watch?v=abcdefghijk">""")) + 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)) + } +} |
