1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
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
|