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
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
|
package org.terst.feedreader.ui
import java.net.URI
// 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.
*
* [baseUrl] is where the article itself lives, used to resolve an image address the feed wrote
* in shorthand. It has no default, deliberately: a caller that omits it does not silently get
* the behaviour that caused this bug -- every image on a site-relative feed like Ftrain's
* un-zoomable -- it fails to compile. Null is still a legitimate answer when there genuinely is
* no article address, but it has to be given on purpose rather than by forgetting.
*/
fun linkifyImagesForZoom(html: String, baseUrl: 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, baseUrl) }
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, baseUrl: 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
else -> resolvedAgainstBase(trimmed, baseUrl)
} ?: 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
}
/**
* What a relative src actually points at, or null if there's no way to tell or it isn't
* something the viewer could open.
*
* A feed's own HTML routinely keeps its images site-relative (Ftrain's <content:encoded> is
* entirely `<img src="/art/uploads/...">`), and that path is what the non-reader rendering
* shows verbatim -- reader mode never hits this because Readability rewrites every src to an
* absolute URL before we see it. The page resolves those against the base URL that
* loadReaderHtml hands loadDataWithBaseURL, which is the article's own link, so resolving them
* the same way here yields exactly the URL the WebView itself fetched. With no base URL there
* is still nothing to resolve against, and the image stays un-zoomable rather than getting a
* dead link.
*/
private fun resolvedAgainstBase(src: String, baseUrl: String?): String? {
if (src.isEmpty() || baseUrl.isNullOrBlank()) return null
// URI() rejects a src with a space or other illegal character in it rather than guessing,
// which is the right answer here too -- an un-zoomable image beats a dead link.
val resolved = runCatching { URI(baseUrl).resolve(src) }.getOrNull()
// data: and javascript: srcs are already absolute, so resolve() hands them straight back;
// only a real network URL is something Coil could ever open.
return when (resolved?.scheme?.lowercase()) {
"http", "https" -> resolved.toString()
else -> null
}
}
/**
* 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
|