summaryrefslogtreecommitdiff
path: root/app/src/main/java/org/terst/feedreader/ui/ImageZoom.kt
blob: 262af8785caa66b24615a9a4aaeb7eb1ddd2b759 (plain)
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
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
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 <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:"

/** 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>, 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

/** 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<Float, Float> {
    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)
            }
        }
    }
}