diff options
Diffstat (limited to 'app/src/main/java/org')
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() } |
