fix: resolve relative URLs against a pathless base on Android

Android's java.net.URI resolves a relative reference against a base with an
empty path by gluing it onto the host: `y18.svg` against
`https://news.ycombinator.com` became `https://news.ycombinator.comy18.svg`,
so Hacker News' compact link preview never loaded its icon (seen on an
SM-T220). The JDK follows RFC 3986 and treats the empty path as `/`, so
normalize the base to that before resolving. Rebuilt from the raw parts so an
encoded query is not encoded twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-10-01 11:08:42 -04:00
co-authored by Claude Opus 5.5
parent ff15246c7b
commit 3d9c5bf0c9
2 changed files with 77 additions and 1 deletions
@@ -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,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"))
}
}