summaryrefslogtreecommitdiff
path: root/app/src/main/java/org/terst
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java/org/terst')
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ImageZoom.kt259
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt7
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt5
-rw-r--r--app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt24
4 files changed, 291 insertions, 4 deletions
diff --git a/app/src/main/java/org/terst/feedreader/ui/ImageZoom.kt b/app/src/main/java/org/terst/feedreader/ui/ImageZoom.kt
new file mode 100644
index 0000000..262af87
--- /dev/null
+++ b/app/src/main/java/org/terst/feedreader/ui/ImageZoom.kt
@@ -0,0 +1,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)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt b/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt
index d71b020..b8956f2 100644
--- a/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/LinkReaderScreen.kt
@@ -82,6 +82,7 @@ fun LinkReaderScreen(url: String, onBack: () -> Unit, onOpenLink: (String) -> Un
var moreMenuExpanded by remember(url) { mutableStateOf(false) }
var longPressedLink by remember(url) { mutableStateOf<String?>(null) }
var youtubeVideoId by remember(url) { mutableStateOf<String?>(null) }
+ var zoomedImageUrl by remember(url) { mutableStateOf<String?>(null) }
LaunchedEffect(url) {
val existing = db.articleDao().getById(articleId)
@@ -237,7 +238,8 @@ fun LinkReaderScreen(url: String, onBack: () -> Unit, onOpenLink: (String) -> Un
ReaderWebViewCallbacks(
onOpenLink = onOpenLink,
onLongPressLink = { longPressedLink = it },
- onYouTubeVideo = { youtubeVideoId = it }
+ onYouTubeVideo = { youtubeVideoId = it },
+ onImageTap = { zoomedImageUrl = it }
)
).apply {
loadDataWithBaseURL(url, wrapReaderHtml(currentExtractedContent, extractedTitle), "text/html", "UTF-8", null)
@@ -289,6 +291,9 @@ fun LinkReaderScreen(url: String, onBack: () -> Unit, onOpenLink: (String) -> Un
youtubeVideoId?.let { videoId ->
YouTubePlayerDialog(videoId = videoId, onDismiss = { youtubeVideoId = null })
}
+ zoomedImageUrl?.let { imageUrl ->
+ ImageZoomDialog(imageUrl = imageUrl, onDismiss = { zoomedImageUrl = null })
+ }
longPressedLink?.let { linkUrl ->
LinkActionsDialog(
url = linkUrl,
diff --git a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
index e2b8aa9..165d421 100644
--- a/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/ReaderScreen.kt
@@ -200,6 +200,7 @@ private fun ReaderContent(
var bugReportDescription by remember(articleId) { mutableStateOf("") }
var longPressedLink by remember(articleId) { mutableStateOf<String?>(null) }
var youtubeVideoId by remember(articleId) { mutableStateOf<String?>(null) }
+ var zoomedImageUrl by remember(articleId) { mutableStateOf<String?>(null) }
LaunchedEffect(articleId) {
val loaded = db.articleDao().getById(articleId) ?: return@LaunchedEffect
@@ -489,6 +490,9 @@ private fun ReaderContent(
youtubeVideoId?.let { videoId ->
YouTubePlayerDialog(videoId = videoId, onDismiss = { youtubeVideoId = null })
}
+ zoomedImageUrl?.let { imageUrl ->
+ ImageZoomDialog(imageUrl = imageUrl, onDismiss = { zoomedImageUrl = null })
+ }
longPressedLink?.let { url ->
LinkActionsDialog(
url = url,
@@ -545,6 +549,7 @@ private fun ReaderContent(
onOpenLink = onOpenLink,
onLongPressLink = { longPressedLink = it },
onYouTubeVideo = { youtubeVideoId = it },
+ onImageTap = { zoomedImageUrl = it },
onScrollChanged = { scrollY -> ReaderScrollPositions.set(articleId, scrollY) },
// The page's real content height isn't known until layout finishes
// -- scrolling to a saved offset any earlier would just clamp back
diff --git a/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt b/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt
index 039255f..9a11dd3 100644
--- a/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt
+++ b/app/src/main/java/org/terst/feedreader/ui/ReaderWebView.kt
@@ -79,6 +79,11 @@ private fun extractYouTubeVideoId(uri: Uri): String? {
* back to the external-open behavior when a confident video ID can't be extracted
* (a playlist/channel link, say).
*
+ * onImageTap fires for a tap on an article image (see ImageZoom.kt for how a tap on an <img>
+ * is detected at all with JS off) with the image's own URL -- callers show a full-screen
+ * ImageZoomDialog. Also fires for a link pointing straight at an image file, which used to
+ * send LinkReaderScreen off to run Readability over a JPEG.
+ *
* Pinch-to-zoom adjusts the persisted reader font size, not WebView's own page zoom
* (never enabled here -- setSupportZoom/builtInZoomControls are left at their defaults,
* which is effectively off). A real page zoom would scale layout and images along with
@@ -109,6 +114,7 @@ data class ReaderWebViewCallbacks(
val onOpenLink: (String) -> Unit,
val onLongPressLink: (String) -> Unit = {},
val onYouTubeVideo: (String) -> Unit = {},
+ val onImageTap: (String) -> Unit = {},
val onScrollChanged: (Int) -> Unit = {},
val onPageFinished: (WebView) -> Unit = {},
val currentFontSizePx: () -> Int = { DEFAULT_READER_FONT_SIZE_PX },
@@ -175,7 +181,13 @@ fun newReaderWebView(context: Context, callbacks: ReaderWebViewCallbacks): WebVi
// worked here anyway.
if (!request.isForMainFrame) return true
val url = request.url
- if (isYouTubeWatchUrl(url)) {
+ // Checked before anything else: an image tap is never a navigation we'd want to
+ // follow. Covers both the zoom links wrapReaderHtml adds to the article's own
+ // images and a link the article itself already pointed straight at an image file.
+ val imageUrl = zoomableImageTarget(url.toString())
+ if (imageUrl != null) {
+ callbacks.onImageTap(imageUrl)
+ } else if (isYouTubeWatchUrl(url)) {
val videoId = extractYouTubeVideoId(url)
if (videoId != null) {
callbacks.onYouTubeVideo(videoId)
@@ -195,7 +207,10 @@ fun newReaderWebView(context: Context, callbacks: ReaderWebViewCallbacks): WebVi
else -> null
}
if (url != null) {
- callbacks.onLongPressLink(url)
+ // Every article image now sits inside a zoom link, so a long-press on one
+ // reports that link's internal scheme -- strip it back to the image's own URL
+ // so the actions menu shows, and acts on, something real.
+ callbacks.onLongPressLink(withoutImageZoomScheme(url))
true
} else {
false
@@ -414,6 +429,9 @@ fun wrapReaderHtml(
fontSizePx: Int = DEFAULT_READER_FONT_SIZE_PX
): String {
val processedBody = replaceGenericIframes(replaceYouTubeIframes(bodyContent))
+ // NB: linkifyImagesForZoom runs at the very end (below, over hero + body together), after
+ // the iframe passes -- the YouTube thumbnail one of them synthesizes is an image inside a
+ // link, and the zoom pass has to see it as one to leave its tap-to-play link alone.
// Some feeds' body HTML never embeds an image at all -- the only image they give us
// is a separate RSS media:content/enclosure field (ArticleEntity.imageUrl), which
// otherwise only ever reaches the list-view thumbnail and never the reader itself
@@ -464,6 +482,6 @@ fun wrapReaderHtml(
</style></head>
<body>${if (!title.isNullOrBlank()) "<h1 class=\"article-title\">${escapeHtml(title)}</h1>" else ""}${
if (publishedAt != null) "<p class=\"article-date\">${escapeHtml(absoluteDateText(publishedAt))}</p>" else ""
- }$heroHtml$processedBody</body></html>
+ }${linkifyImagesForZoom(heroHtml + processedBody)}</body></html>
""".trimIndent()
}