Merge pull request #4298 from vitorpamplona/ccr-800e8165-xz9npv

Preview pages without OpenGraph from title, description, and favicon
This commit is contained in:
Vitor Pamplona
2026-10-01 11:18:57 -04:00
committed by GitHub
12 changed files with 837 additions and 46 deletions
@@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -39,6 +40,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
@@ -239,11 +241,13 @@ private fun MyLoadUrlPreviewDirect(
)
} else {
Box(contentAlignment = Alignment.BottomCenter, modifier = Modifier.aspectRatio(1f)) {
// A page with no OpenGraph image still has its icon: shown whole, not cropped.
val hasImage = state.previewInfo.imageUrlFullPath.isNotBlank()
AsyncImage(
model = state.previewInfo.imageUrlFullPath,
model = if (hasImage) state.previewInfo.imageUrlFullPath else state.previewInfo.iconUrlFullPath,
contentDescription = state.previewInfo.title,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
contentScale = if (hasImage) ContentScale.Crop else ContentScale.Fit,
modifier = if (hasImage) Modifier.fillMaxSize() else Modifier.fillMaxSize().padding(20.dp),
)
Text(
@@ -49,7 +49,7 @@ class HtmlParser {
?: bodyBytes.bomCharsetName()
?: HtmlCharsetParser.detectCharset(bodyBytes)
val content = decodeBytes(bodyBytes, name)
MetaTagsParser.parse(content)
MetaTagsParser.parse(content, includeTitleAndIcons = true)
}
private fun ByteArray.bomCharsetName(): String? {
@@ -22,8 +22,21 @@ package com.vitorpamplona.amethyst.commons.preview
import com.vitorpamplona.amethyst.commons.util.codePointToChars
/** The `<head>` element a [MetaTag] was read from. */
enum class HeadElement {
/** A `<meta>` tag, the only kind [MetaTagsParser.parse] yields unless asked for more. */
META,
/** The document's `<title>`, its text carried as the `content` attribute. */
TITLE,
/** A `<link>` whose `rel` names an icon (`icon`, `shortcut icon`, `apple-touch-icon`, …). */
LINK,
}
data class MetaTag(
private val attrs: Map<String, String>,
val element: HeadElement = HeadElement.META,
) {
/**
* Returns a value of an attribute specified by its name (case insensitive), or empty string if it doesn't exist.
@@ -43,7 +56,17 @@ object MetaTagsParser {
private const val NO_QUOTE = ' '
private const val META = "meta"
private const val LINK = "link"
private const val HEAD = "head"
private const val BODY = "body"
private const val ICON = "icon"
private const val REL = "rel"
private const val CONTENT = "content"
// A `<title>` is only a fallback label, so one that runs on (an unclosed tag swallowing the
// page, a spam keyword dump) is cut rather than carried into the preview cache whole.
private const val MAX_TITLE_LENGTH = 300
private val WHITESPACE_RUN = Regex("\\s+")
// Elements whose content is text rather than markup: script and style hold raw text, title and
// textarea hold character data. A `<` inside any of them is not a tag.
@@ -66,26 +89,99 @@ object MetaTagsParser {
/** The `</head>` that ends the interesting part of the document. */
HEAD_END,
/**
* A `<body>` start tag. `</head>` is optional, so on a page that omits it this is the only
* sign the head is over.
*/
BODY_START,
/** A closed `<title>`: its text is [TagScanner.textStart]..<[TagScanner.textEnd]. */
TITLE,
/** A `<link …>` whose attribute span mentions `icon`; same span fields as [META]. */
LINK,
/** Anything else: other elements, comments, declarations, unparseable markup. */
OTHER,
}
/**
* Lazily parse a partial HTML document and extract meta tags.
*
* With [includeTitleAndIcons], the document's `<title>` and its icon `<link>`s are yielded
* too, tagged by [MetaTag.element]. They are what a page without OpenGraph still offers for a
* preview: browsers show exactly these two in a tab.
*/
fun parse(input: String): Sequence<MetaTag> =
fun parse(
input: String,
includeTitleAndIcons: Boolean = false,
): Sequence<MetaTag> =
sequence {
val s = TagScanner(input)
// Meta tags past an implicit end of head are still read, as they always were. The
// title and icons are not: past `<body>`, a `<title>` is an inline SVG's tooltip
// ("Close", "Menu"), not the page's name.
var inBody = false
while (!s.exhausted()) {
val kind = s.nextTag()
if (kind == TagKind.HEAD_END) break
if (kind == TagKind.META) {
val attrs = parseAttrs(input, s.attrsStart, s.attrsEnd) ?: continue
yield(MetaTag(attrs))
when (s.nextTag()) {
TagKind.HEAD_END -> {
break
}
TagKind.BODY_START -> {
inBody = true
}
TagKind.META -> {
val attrs = parseAttrs(input, s.attrsStart, s.attrsEnd) ?: continue
yield(MetaTag(attrs))
}
TagKind.TITLE -> {
if (includeTitleAndIcons && !inBody) {
val text = titleText(input, s.textStart, s.textEnd)
if (text.isNotEmpty()) yield(MetaTag(mapOf(CONTENT to text), HeadElement.TITLE))
}
}
TagKind.LINK -> {
if (includeTitleAndIcons && !inBody) {
val attrs = parseAttrs(input, s.attrsStart, s.attrsEnd) ?: continue
if (isIconRel(attrs[REL])) yield(MetaTag(attrs, HeadElement.LINK))
}
}
TagKind.OTHER -> {}
}
}
}
/**
* Whether a `rel` names an icon: one of its space-separated tokens is `icon` or an
* `apple-touch-icon` variant. `mask-icon` is deliberately not one -- it is Safari's monochrome
* pinned-tab silhouette, which renders as a black blob anywhere else.
*/
private fun isIconRel(rel: String?): Boolean =
rel != null &&
rel.split(' ', '\t', '\n', '\r', '\u000C').any {
it.equals(ICON, ignoreCase = true) || it.startsWith("apple-touch-icon", ignoreCase = true)
}
/** The `<title>`'s character data, with references resolved and whitespace collapsed. */
private fun titleText(
input: String,
from: Int,
to: Int,
): String {
val raw = input.substring(from, to)
val decoded = if (raw.indexOf('&') < 0) raw else raw.replace(Attrs.RE_CHAR_REF, Attrs.Companion::replaceCharRefs)
val title = decoded.replace(WHITESPACE_RUN, " ").trim()
if (title.length <= MAX_TITLE_LENGTH) return title
// Never end on half of a surrogate pair: a lone high surrogate renders as tofu.
val cut = if (title[MAX_TITLE_LENGTH - 1].isHighSurrogate()) MAX_TITLE_LENGTH - 1 else MAX_TITLE_LENGTH
return title.substring(0, cut)
}
private class TagScanner(
private val input: String,
) {
@@ -98,6 +194,12 @@ object MetaTagsParser {
var attrsEnd = 0
private set
/** Character-data span of the `<title>` [nextTag] last reported as [TagKind.TITLE]. */
var textStart = 0
private set
var textEnd = 0
private set
fun exhausted(): Boolean = p >= length
/**
@@ -128,6 +230,20 @@ object MetaTagsParser {
p = if (end < 0) length else end + 1
}
/** True when `input[from..<to]` contains [lower], ASCII-case-insensitively. */
private fun spanContains(
from: Int,
to: Int,
lower: String,
): Boolean {
var i = from
while (i + lower.length <= to) {
if (input.regionMatches(i, lower, 0, lower.length, ignoreCase = true)) return true
i++
}
return false
}
/** Leaves [p] on the `</name` that closes a raw-text element, or at the end of the input. */
private fun skipRawText(endTag: String) {
var i = p
@@ -215,14 +331,25 @@ object MetaTagsParser {
// the same way an unbalanced quote inside a comment does. Switching on the name length
// first keeps the common tag (a `<div>`, a `<link>`) down to one comparison.
when (nameEnd - nameStart) {
META.length -> if (nameIs(nameStart, nameEnd, META)) return TagKind.META
META.length -> {
if (nameIs(nameStart, nameEnd, META)) return TagKind.META
if (nameIs(nameStart, nameEnd, BODY)) return TagKind.BODY_START
// Most `<link>`s are stylesheets and preloads; checking the raw span for `icon`
// keeps their attributes from ever being parsed into a map.
if (nameIs(nameStart, nameEnd, LINK) && spanContains(attrsStart, attrsEnd, ICON)) return TagKind.LINK
}
STYLE.length ->
STYLE.length -> {
if (nameIs(nameStart, nameEnd, STYLE)) {
skipRawText(STYLE_END)
} else if (nameIs(nameStart, nameEnd, TITLE)) {
textStart = p
skipRawText(TITLE_END)
textEnd = p
// An unclosed title runs to the end of the input: that is not a title.
if (p < length) return TagKind.TITLE
}
}
SCRIPT.length -> if (nameIs(nameStart, nameEnd, SCRIPT)) skipRawText(SCRIPT_END)
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.preview
class OpenGraphParser {
class Result(
/** The OpenGraph/Twitter/meta title, or the document's `<title>` when it declares none. */
val title: String,
val description: String,
val image: String,
@@ -35,6 +36,8 @@ class OpenGraphParser {
val videoType: String = "",
/** `og:type` — what the page says it *is*, e.g. `music.song`, `video.other`, `article`. */
val type: String = "",
/** The best icon `<link>` the page declares, verbatim (may be relative). Empty when none. */
val icon: String = "",
)
companion object {
@@ -112,6 +115,17 @@ class OpenGraphParser {
)
private val CONTENT = "content"
private val HREF = "href"
private val REL = "rel"
private val SIZES = "sizes"
private val TYPE = "type"
// How an icon with no `sizes` is ranked. An `apple-touch-icon` is 180px by Apple's
// convention; a plain `icon` without sizes is usually the 16/32px tab favicon. A scalable
// one (SVG, or `sizes="any"`) beats every bitmap: it is sharp at any card size.
private const val APPLE_TOUCH_ICON_DEFAULT_SIZE = 180
private const val ICON_DEFAULT_SIZE = 32
private const val SCALABLE_ICON_SIZE = Int.MAX_VALUE
}
/** Which field of [Result] a meta tag's key fills, or null when the key is not one we read. */
@@ -156,8 +170,33 @@ class OpenGraphParser {
var video = ""
var videoType = ""
var type = ""
var documentTitle = ""
var icon = ""
var iconSize = -1
metaTags.forEach {
when (it.element) {
HeadElement.META -> {}
HeadElement.TITLE -> {
if (documentTitle.isEmpty()) documentTitle = it.attr(CONTENT)
return@forEach
}
HeadElement.LINK -> {
val href = it.attr(HREF)
if (href.isNotBlank()) {
val size = iconSize(it)
// Strictly greater, so among equals the first declared wins.
if (size > iconSize) {
icon = href
iconSize = size
}
}
return@forEach
}
}
// A meta tag names its key in exactly one of these three attributes, but which one
// varies by site, so each is tried in turn until one is a key we read.
val field =
@@ -179,6 +218,32 @@ class OpenGraphParser {
null -> Unit
}
}
return Result(title, description, image, audio, audioType, video, videoType, type)
return Result(title.ifEmpty { documentTitle }, description, image, audio, audioType, video, videoType, type, icon)
}
/** The pixel size an icon `<link>` is ranked by: its largest declared size, or a default. */
private fun iconSize(link: MetaTag): Int {
val sizes = link.attr(SIZES)
if (sizes.contains("any", ignoreCase = true)) return SCALABLE_ICON_SIZE
if (link.attr(TYPE).equals("image/svg+xml", ignoreCase = true)) return SCALABLE_ICON_SIZE
if (link
.attr(HREF)
.substringBefore('?')
.substringBefore('#')
.endsWith(".svg", ignoreCase = true)
) {
return SCALABLE_ICON_SIZE
}
// `sizes="16x16 32x32"`: rank by the largest, which is the one a decoder will pick.
val declared =
sizes
.split(' ')
.mapNotNull { it.lowercase().substringBefore('x', "").toIntOrNull() }
.maxOrNull()
if (declared != null) return declared
val isAppleTouch = link.attr(REL).contains("apple-touch-icon", ignoreCase = true)
return if (isAppleTouch) APPLE_TOUCH_ICON_DEFAULT_SIZE else ICON_DEFAULT_SIZE
}
}
@@ -43,6 +43,8 @@ class UrlInfoItem(
val videoType: String = "",
/** `og:type` — what the page says it is, e.g. `music.song`, `video.other`, `article`. */
val type: String = "",
/** The page's best declared icon `<link>` (favicon / apple-touch-icon). Empty when none. */
val icon: String = "",
) {
/** The page's host, or null when [url] is not an absolute URL. */
val verifiedHost = absoluteUrlHost(url)
@@ -66,6 +68,24 @@ class UrlInfoItem(
// which is what it did before. Only the playable gate below treats unresolvable as refusal.
val imageUrlFullPath = resolve(image) ?: image
/**
* The page's icon, absolute: the one it declared, else `/favicon.ico` at its origin -- the
* same guess every browser makes, since most sites that declare nothing still serve one.
*
* A `data:` icon is used as-is when it is an image (the image loader decodes those inline),
* and taken at its word when it is not: `href="data:,"` is how a site tells browsers it has no
* favicon, so guessing `/favicon.ico` there is a request that is known to miss.
*
* Null for a non-HTML URL (an image or a video is its own preview) or an unresolvable one.
*/
val iconUrlFullPath: String? =
when {
!mimeType.startsWith("text/html", ignoreCase = true) -> null
icon.startsWith("data:image/", ignoreCase = true) -> if (icon.length <= MAX_INLINE_ICON_LENGTH) icon else resolve(DEFAULT_FAVICON)
icon.startsWith("data:", ignoreCase = true) -> null
else -> resolve(icon) ?: resolve(DEFAULT_FAVICON)
}
/**
* The declared `og:video`, absolute, but only when the declaration holds up: a [videoType] the
* renderer can actually play (a `video/` MIME, an `audio/` one, or an HLS playlist type), or
@@ -119,13 +139,29 @@ class UrlInfoItem(
else -> audioType.ifEmpty { null }
}
/**
* Whether the page has text to show without an image: a title (OpenGraph's or the document's
* own `<title>`) or a description. Such a page gets the compact icon + text card rather than
* a bare link.
*/
val hasTextPreview: Boolean = title.isNotBlank() || description.isNotBlank()
/**
* Whether the fetch produced something worth rendering. An image is the usual evidence, but a
* page that declared playable media counts too — a track page that ships no cover art would
* otherwise be thrown away as Empty and fall back to a bare link, which is exactly the player
* we went to the trouble of finding.
* we went to the trouble of finding. So does a page with no OpenGraph at all that still has a
* title or description: it renders as the compact card instead of a bare link.
*/
fun fetchComplete(): Boolean = url.isNotEmpty() && (image.isNotEmpty() || playableMediaUrl != null)
fun fetchComplete(): Boolean = url.isNotEmpty() && (image.isNotEmpty() || playableMediaUrl != null || hasTextPreview)
fun allFetchComplete(): Boolean = title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()
companion object {
private const val DEFAULT_FAVICON = "/favicon.ico"
// Previews are cached, so an inline icon is held for as long as its card is. Real inline
// favicons are a few KB; anything far past that is not worth keeping in memory.
private const val MAX_INLINE_ICON_LENGTH = 64 * 1024
}
}
@@ -0,0 +1,213 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.preview
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* A page with no OpenGraph still has what a browser tab shows: its `<title>` and its icon
* `<link>`s. These are read only when asked for, and only ever as a fallback.
*/
class TitleAndIconFallbackTest {
private fun headTags(html: String) = MetaTagsParser.parse(html, includeTitleAndIcons = true).toList()
private fun extract(html: String) = OpenGraphParser().extractUrlInfo(MetaTagsParser.parse(html, includeTitleAndIcons = true))
// Trimmed from the nsite that prompted this: no og:*, no twitter:*, only a title, a meta
// description and an SVG favicon.
private val noOpenGraphPage =
"""
|<!DOCTYPE html>
|<html lang="en">
|<head>
|<meta charset="utf-8">
|<meta name="viewport" content="width=device-width, initial-scale=1">
|<title>Private Provider &mdash; Confidential AI</title>
|<meta name="description" content="A phone-first AI workspace for Android.">
|<link rel="icon" href="favicon.svg" type="image/svg+xml">
|<link rel="stylesheet" href="styles.css">
|</head>
|<body><h1>Hi</h1></body>
|</html>
""".trimMargin()
@Test
fun aPageWithoutOpenGraphStillYieldsTitleDescriptionAndIcon() {
val info = extract(noOpenGraphPage)
assertEquals("Private Provider — Confidential AI", info.title)
assertEquals("A phone-first AI workspace for Android.", info.description)
assertEquals("favicon.svg", info.icon)
assertEquals("", info.image)
}
@Test
fun theDefaultParseStillYieldsOnlyMetaTags() {
val tags = MetaTagsParser.parse(noOpenGraphPage).toList()
assertEquals(3, tags.size)
assertTrue(tags.all { it.element == HeadElement.META })
}
@Test
fun onlyIconLinksAreYielded() {
val tags = headTags(noOpenGraphPage)
val links = tags.filter { it.element == HeadElement.LINK }
assertEquals(1, links.size)
assertEquals("favicon.svg", links[0].attr("href"))
}
@Test
fun openGraphTitleWinsOverTheDocumentTitle() {
val info =
extract(
"""
|<head>
| <title>Document Title</title>
| <meta property="og:title" content="OG Title">
|</head>
""".trimMargin(),
)
assertEquals("OG Title", info.title)
}
@Test
fun titleWhitespaceIsCollapsed() {
val info = extract("<head><title>\n Spread\n\t Out </title></head>")
assertEquals("Spread Out", info.title)
}
@Test
fun anUnclosedTitleIsNotATitle() {
val info = extract("<head><title>runs to the end of the truncated body")
assertEquals("", info.title)
}
@Test
fun aTitleOutsideTheHeadIsIgnored() {
// An SVG in the body carries its own <title>; the scan stops at </head> before it.
val info = extract("<head></head><body><svg><title>Close</title></svg></body>")
assertEquals("", info.title)
}
@Test
fun theFirstTitleWins() {
val info = extract("<head><title>First</title><title>Second</title></head>")
assertEquals("First", info.title)
}
@Test
fun theLargestIconWins() {
val info =
extract(
"""
|<head>
| <link rel="shortcut icon" href="/favicon.ico">
| <link rel="icon" sizes="16x16 32x32" href="/icon-32.png">
| <link rel="apple-touch-icon" href="/apple-touch-icon.png">
| <link rel="icon" sizes="96X96" href="/icon-96.png">
|</head>
""".trimMargin(),
)
assertEquals("/apple-touch-icon.png", info.icon)
}
@Test
fun aScalableIconBeatsBitmaps() {
val info =
extract(
"""
|<head>
| <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
| <link rel="icon" href="/icon.svg?v=2">
|</head>
""".trimMargin(),
)
assertEquals("/icon.svg?v=2", info.icon)
}
@Test
fun maskIconIsNotUsed() {
// Safari's monochrome pinned-tab silhouette renders as a black blob anywhere else.
val info =
extract(
"""
|<head>
| <link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
| <link rel="icon" href="/favicon-32.png">
|</head>
""".trimMargin(),
)
assertEquals("/favicon-32.png", info.icon)
}
@Test
fun iconLinksAfterTheHeadAreIgnored() {
val info = extract("""<head></head><body><link rel="icon" href="/late.png"></body>""")
assertEquals("", info.icon)
}
@Test
fun aBodyTitleIsIgnoredWhenTheHeadIsNeverClosed() {
// `</head>` is optional: `<body>` closes the head implicitly. An inline SVG's <title>
// ("Close", "Menu") is the classic thing to then mistake for the page title.
val info = extract("""<head><meta name="description" content="D"><body><svg><title>Close</title></svg></body>""")
assertEquals("", info.title)
assertEquals("D", info.description)
}
@Test
fun aBodyIconLinkIsIgnoredWhenTheHeadIsNeverClosed() {
val info = extract("""<head><title>T</title><body><link rel="icon" href="/late.png"></body>""")
assertEquals("T", info.title)
assertEquals("", info.icon)
}
@Test
fun aTitleWithAttributesIsRead() {
val info = extract("""<head><title data-rh="true">Helmet</title></head>""")
assertEquals("Helmet", info.title)
}
@Test
fun aLongTitleIsNotCutInsideASurrogatePair() {
// 299 ASCII chars then an emoji: a plain take(300) keeps only its high surrogate.
val info = extract("<head><title>" + "a".repeat(299) + "\uD83D\uDE00 tail</title></head>")
assertTrue(info.title.isNotEmpty())
assertTrue(!info.title.last().isHighSurrogate(), "title ends in a dangling high surrogate")
}
}
@@ -77,6 +77,7 @@ class UrlPreview {
data.video,
data.videoType,
data.type,
data.icon,
)
}
@@ -29,7 +29,7 @@ actual fun resolveHttpUrl(
reference: String,
): String? =
runCatching {
val baseUri = base?.let { runCatching { URI(it).toURL().toURI() }.getOrNull() }
val baseUri = base?.let { runCatching { URI(it).toURL().toURI().withRootPath() }.getOrNull() }
val resolved = if (baseUri != null) baseUri.resolve(reference) else URI(reference)
if (!resolved.scheme.equals("http", ignoreCase = true) &&
!resolved.scheme.equals("https", ignoreCase = true)
@@ -39,3 +39,23 @@ actual fun resolveHttpUrl(
resolved.toURL().toString()
}
}.getOrNull()
/**
* `https://host` as `https://host/`. RFC 3986 resolves a relative reference against an empty base path
* as if the path were `/`, and the JDK does, but Android's `java.net.URI` does not: it glued
* `y18.svg` onto `https://news.ycombinator.com` as `https://news.ycombinator.comy18.svg`, so the
* page's icon (or a relative og:image) pointed at a host that does not exist.
*/
private fun URI.withRootPath(): URI =
if (isOpaque || !rawPath.isNullOrEmpty() || rawAuthority == null) {
this
} else {
// From the raw (still-encoded) parts: the multi-argument constructor would encode them again.
URI(
buildString {
append(scheme).append("://").append(rawAuthority).append('/')
rawQuery?.let { append('?').append(it) }
rawFragment?.let { append('#').append(it) }
},
)
}
@@ -0,0 +1,109 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.preview
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* A page with no OpenGraph image is still worth a card when it has text: the compact icon + text
* card, built from its title, description and favicon.
*/
class UrlInfoItemTextPreviewTest {
private fun page(
url: String = "https://example.nsite.lol/",
title: String = "",
description: String = "",
image: String = "",
icon: String = "",
mimeType: String = "text/html; charset=utf-8",
) = UrlInfoItem(url = url, title = title, description = description, image = image, mimeType = mimeType, icon = icon)
@Test
fun aTitleAloneIsEnoughToKeepThePreview() {
assertTrue(page(title = "Private Provider").fetchComplete())
}
@Test
fun aDescriptionAloneIsEnoughToKeepThePreview() {
assertTrue(page(description = "A workspace").fetchComplete())
}
@Test
fun aPageWithNothingStaysEmpty() {
assertFalse(page().fetchComplete())
assertFalse(page(title = " ").fetchComplete())
}
@Test
fun aRelativeIconIsResolvedAgainstThePage() {
assertEquals(
"https://example.nsite.lol/docs/favicon.svg",
page(url = "https://example.nsite.lol/docs/index.html", icon = "favicon.svg").iconUrlFullPath,
)
}
@Test
fun noDeclaredIconFallsBackToTheOriginFavicon() {
assertEquals(
"https://example.nsite.lol/favicon.ico",
page(url = "https://example.nsite.lol/a/b/c.html").iconUrlFullPath,
)
}
@Test
fun aNonHttpIconFallsBackToTheOriginFavicon() {
assertEquals(
"https://example.nsite.lol/favicon.ico",
page(icon = "file:///etc/passwd").iconUrlFullPath,
)
}
@Test
fun nonHtmlUrlsHaveNoIcon() {
assertNull(page(url = "https://example.com/a.png", image = "https://example.com/a.png", mimeType = "image/png").iconUrlFullPath)
}
@Test
fun anUppercaseHtmlMimeStillGetsAnIcon() {
// UrlPreview stores MediaType.toString(), which keeps the server's spelling.
assertEquals(
"https://example.nsite.lol/favicon.ico",
page(mimeType = "Text/HTML; charset=UTF-8").iconUrlFullPath,
)
}
@Test
fun anEmptyDataIconMeansTheSiteHasNoIcon() {
// `<link rel="icon" href="data:,">` is the common way to tell browsers not to request
// /favicon.ico. Requesting it anyway is a guaranteed miss.
assertNull(page(icon = "data:,").iconUrlFullPath)
}
@Test
fun anInlineImageIconIsUsedAsIs() {
val inline = "data:image/png;base64,iVBORw0KGgo="
assertEquals(inline, page(icon = inline).iconUrlFullPath)
}
}
@@ -160,6 +160,19 @@ class MetaTagsParserBenchmark {
}
bench("no </head>, whole doc", open, 2_000) { MetaTagsParser.parse(it).count() }
// What production runs (HtmlParser asks for the <title> and icon <link>s too). Should sit
// on top of the rows above: the 60 preload links never reach the attribute parser.
bench("spa head, +title/icons", spa, 50_000) { MetaTagsParser.parse(it, includeTitleAndIcons = true).count() }
bench("spa head, og: + fallbacks", spa, 50_000) {
OpenGraphParser().extractUrlInfo(MetaTagsParser.parse(it, includeTitleAndIcons = true)).title.length
}
bench("heavy head, +title/icons", heavy, 2_000) { MetaTagsParser.parse(it, includeTitleAndIcons = true).count() }
bench("no </head>, +title/icons", open, 2_000) { MetaTagsParser.parse(it, includeTitleAndIcons = true).count() }
val spaFallbacks = OpenGraphParser().extractUrlInfo(MetaTagsParser.parse(spa, includeTitleAndIcons = true))
assertEquals("Example — Your Network. Your Rules.", spaFallbacks.title)
assertEquals("/favicon.svg", spaFallbacks.icon)
assertTrue(MetaTagsParser.parse(open).count() >= 64)
}
}
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.util
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* A relative reference against a base with no path resolves as if the path were `/` (RFC 3986).
* Android's `java.net.URI` got this wrong — `y18.svg` against `https://news.ycombinator.com` became
* `https://news.ycombinator.comy18.svg` — so the base is normalized before resolving.
*/
class HttpUrlResolutionTest {
@Test
fun relativeReferenceAgainstAPathlessBase() {
assertEquals("https://news.ycombinator.com/y18.svg", resolveHttpUrl("https://news.ycombinator.com", "y18.svg"))
assertEquals("https://news.ycombinator.com/favicon.ico", resolveHttpUrl("https://news.ycombinator.com", "/favicon.ico"))
}
@Test
fun pathlessBaseKeepsItsQueryAndEncoding() {
assertEquals("https://a.example/icon.png", resolveHttpUrl("https://a.example?q=a%20b", "icon.png"))
// Encoded characters are carried over as they are, not encoded a second time.
assertEquals("https://a.example/icons/a%20b.png", resolveHttpUrl("https://a.example?q=a%20b", "icons/a%20b.png"))
}
@Test
fun basesWithAPathAreUnchanged() {
assertEquals("https://a.example/dir/icon.png", resolveHttpUrl("https://a.example/dir/page.html", "icon.png"))
assertEquals("https://cdn.example/x.png", resolveHttpUrl("https://a.example/dir/", "//cdn.example/x.png"))
}
@Test
fun nonHttpStaysRefused() {
assertNull(resolveHttpUrl("https://a.example", "file:///etc/passwd"))
}
}
@@ -20,26 +20,37 @@
*/
package com.vitorpamplona.amethyst.commons.ui.components
import androidx.collection.LruCache
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.network.HttpException
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.preview.UrlInfoItem
@@ -50,9 +61,12 @@ import com.vitorpamplona.amethyst.commons.resources.link_actions_dialog_title
import com.vitorpamplona.amethyst.commons.resources.url_preview_open_in_browser
import com.vitorpamplona.amethyst.commons.ui.components.util.setText
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.MaxWidthWithHorzPadding
import com.vitorpamplona.amethyst.commons.ui.theme.Size14Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size24Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.commons.ui.theme.innerPostModifier
import com.vitorpamplona.amethyst.commons.ui.theme.previewCardImageModifier
import kotlinx.coroutines.launch
@@ -101,22 +115,50 @@ fun UrlPreviewCard(
}
}
Column(
modifier =
MaterialTheme.colorScheme.innerPostModifier
.combinedClickable(
onClick = {
if (onCardClick != null) {
onCardClick()
} else {
runCatching { uri.openUri(url) }
}
},
onLongClick = {
popupExpanded.value = true
},
),
) {
val cardModifier =
MaterialTheme.colorScheme.innerPostModifier
.combinedClickable(
onClick = {
if (onCardClick != null) {
onCardClick()
} else {
runCatching { uri.openUri(url) }
}
},
onLongClick = {
popupExpanded.value = true
},
)
// Only meaningful when the card's own tap does something else (e.g. opening the comment
// thread); otherwise it would duplicate the card's open-in-browser tap.
val hasCardClick = onCardClick != null
val onOpenInBrowser: (() -> Unit)? =
remember(hasCardClick, url, uri) {
if (hasCardClick) {
{ runCatching { uri.openUri(url) } }
} else {
null
}
}
// A page with no picture to lead with -- most often one with no OpenGraph at all, previewed
// from its `<title>`, meta description and favicon -- gets the short, wide card. Painting the
// big layout without its image would leave a text block that only looks broken.
if (previewInfo.imageUrlFullPath.isBlank() && previewInfo.hasTextPreview) {
CompactUrlPreviewCard(previewInfo, cardModifier, onOpenInBrowser)
} else {
LargeUrlPreviewCard(previewInfo, cardModifier, onOpenInBrowser)
}
}
@Composable
private fun LargeUrlPreviewCard(
previewInfo: UrlInfoItem,
modifier: Modifier,
onOpenInBrowser: (() -> Unit)?,
) {
Column(modifier = modifier) {
// A Loaded preview no longer implies an image: a player page that ships no cover art is
// kept (it has media to play), and painting its empty string left a blank 180dp box.
if (previewInfo.imageUrlFullPath.isNotBlank()) {
@@ -141,20 +183,7 @@ fun UrlPreviewCard(
overflow = TextOverflow.Ellipsis,
)
// Only meaningful when the card's own tap does something else (e.g. opening the
// comment thread); otherwise it would duplicate the card's open-in-browser tap.
if (onCardClick != null) {
IconButton(
onClick = { runCatching { uri.openUri(url) } },
) {
Icon(
symbol = MaterialSymbols.AutoMirrored.OpenInNew,
contentDescription = stringRes(Res.string.url_preview_open_in_browser),
modifier = Size14Modifier,
tint = Color.Gray,
)
}
}
onOpenInBrowser?.let { OpenInBrowserButton(it) }
}
Text(
@@ -177,3 +206,121 @@ fun UrlPreviewCard(
Spacer(modifier = DoubleVertSpacer)
}
}
/**
* The short, wide card: the site's icon on the left, its title, description and host on the
* right. Built from what every page has even without OpenGraph -- what a browser tab shows.
*/
@Composable
private fun CompactUrlPreviewCard(
previewInfo: UrlInfoItem,
modifier: Modifier,
onOpenInBrowser: (() -> Unit)?,
) {
val host = previewInfo.verifiedHost ?: previewInfo.url
Row(
modifier = modifier.padding(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
UrlPreviewIcon(previewInfo.iconUrlFullPath, host)
Spacer(modifier = DoubleHorzSpacer)
Column(modifier = Modifier.weight(1f)) {
Text(
text = previewInfo.title.ifBlank { host },
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (previewInfo.description.isNotBlank()) {
Text(
text = previewInfo.description,
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
// When there is no title the host already stands in for it on the first line.
if (previewInfo.title.isNotBlank()) {
Text(
text = host,
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
onOpenInBrowser?.let { OpenInBrowserButton(it) }
}
}
/**
* Icon URLs the server refused (404 and the like) in this process. The image loader caches
* successes but not failures, so without this a site with no `/favicon.ico` would be asked for it
* again -- and 404 again -- every time its card scrolled back into view.
*/
private val failedIconUrls = LruCache<String, Unit>(200)
/**
* The site's icon in a fixed square. A generic globe sits there until the icon actually loads,
* so a site whose favicon is missing (the `/favicon.ico` guess is only a guess) or undecodable
* still gets a tidy card instead of an empty hole.
*/
@Composable
private fun UrlPreviewIcon(
iconUrl: String?,
contentDescription: String,
) {
Box(
modifier =
UrlPreviewIconModifier
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)),
contentAlignment = Alignment.Center,
) {
var loaded by remember(iconUrl) { mutableStateOf(false) }
if (!loaded) {
Icon(
symbol = MaterialSymbols.Language,
contentDescription = null,
modifier = Size24Modifier,
tint = Color.Gray,
)
}
if (iconUrl != null && failedIconUrls[iconUrl] == null) {
AsyncImage(
model = iconUrl,
contentDescription = contentDescription,
contentScale = ContentScale.Fit,
modifier = UrlPreviewIconImageModifier,
onSuccess = { loaded = true },
// Only a server's answer is remembered. A network error (offline, a timeout) says
// nothing about the icon and must be retried once the connection is back.
onError = { if (it.result.throwable is HttpException) failedIconUrls.put(iconUrl, Unit) },
)
}
}
}
@Composable
private fun OpenInBrowserButton(onClick: () -> Unit) {
IconButton(onClick = onClick) {
Icon(
symbol = MaterialSymbols.AutoMirrored.OpenInNew,
contentDescription = stringRes(Res.string.url_preview_open_in_browser),
modifier = Size14Modifier,
tint = Color.Gray,
)
}
}
private val UrlPreviewIconModifier = Modifier.size(48.dp).clip(SmallBorder)
private val UrlPreviewIconImageModifier = Modifier.fillMaxSize().padding(6.dp)