summaryrefslogtreecommitdiff
path: root/app/src/main/java/org/terst/feedreader/ui/ImageZoomLinks.kt
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java/org/terst/feedreader/ui/ImageZoomLinks.kt')
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ImageZoomLinks.kt106
1 files changed, 106 insertions, 0 deletions
diff --git a/app/src/main/java/org/terst/feedreader/ui/ImageZoomLinks.kt b/app/src/main/java/org/terst/feedreader/ui/ImageZoomLinks.kt
new file mode 100644
index 0000000..b83b600
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/ui/ImageZoomLinks.kt
@@ -0,0 +1,106 @@
+package org.terst.feedreader.ui
+
+// How a tap on an article image is noticed at all, and turned into a URL the viewer
+// (ImageZoom.kt) can open. Split from the viewer itself because this half runs at
+// HTML-assembly time, inside wrapReaderHtml -- it's reader-markup plumbing, not gestures.
+//
+// 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 <img> 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:"
+
+// Matches an opening <a>, a closing </a>, or an <img> -- in document order, so a single pass
+// can tell which images are already inside a link. `<a\b` deliberately doesn't match <abbr>,
+// <article> etc., since \b requires a non-word character right after the "a".
+private val anchorOrImageTagRegex = Regex("""<a\b[^>]*>|</a\s*>|<img\b[^>]*>""", 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("</", ignoreCase = true) -> if (anchorDepth > 0) anchorDepth--
+ text.startsWith("<a", ignoreCase = true) -> 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("<a href=\"").append(href).append("\">").append(text).append("</a>")
+ 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