fix: read the query of an opaque uri on JVM and Android

`amethyst+walletconnect:dlnwc?value=...` came back as "Amethyst received a
URI to open but that uri was invalid", while the `//` spelling of the very
same link worked. isWalletConnectRoute() accepts three spellings; only two
survived the parse behind it.

A uri whose scheme is followed by anything but `/` is *opaque* to
java.net.URI: it reports no query at all and folds `dlnwc?value=...` into one
scheme-specific part. The linux actual of UriParser -- a hand-rolled parser
-- looks for `?` wherever it sits, so the actuals disagreed and the JVM one
was the one losing data. Nip47DeepLink.parseCallbackValue rides the same
call, so NWC callbacks were exposed to it too.

The query is now recovered from the scheme-specific part when the uri is
opaque. The fragment needs no such rescue: java.net.URI does parse `#...` off
an opaque uri, and a test pins that.

UriParserTest states the contract for every actual. The existing
UriToRouteTest only covered the bare `dlnwc?value=` form -- the spelling that
already worked -- which is how this went unnoticed; it now covers all three,
and fails on the one-colon form without this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-09-21 17:15:54 -04:00
co-authored by Claude Opus 5
parent 6974ddcba9
commit 1f18e85863
3 changed files with 136 additions and 1 deletions
@@ -93,6 +93,24 @@ class UriToRouteTest {
) )
} }
@Test
fun walletConnectDeepLinksRouteInEverySpellingIsWalletConnectRouteAccepts() {
// isWalletConnectRoute() accepts three spellings, so all three have to survive the parse
// behind it. They did not: `java.net.URI` calls a scheme followed by anything but `/` an
// *opaque* uri and reports it as having no query at all, so the one-colon form lost its
// `value=` and came back to the user as "that uri was invalid" -- while the `//` spelling
// of the very same link worked. Only the bare form was covered here before.
val encoded = URLEncoder.encode(NWC_URI, Charsets.UTF_8.name())
listOf(
"dlnwc?value=$encoded",
"amethyst+walletconnect:dlnwc?value=$encoded",
"amethyst+walletconnect://dlnwc?value=$encoded",
).forEach { deepLink ->
assertEquals(deepLink, Route.WalletAddNwc(NWC_URI), uriToRoute(deepLink, account))
}
}
@Test @Test
fun theLauncherShortcutOpensTheScannerDirectly() { fun theLauncherShortcutOpensTheScannerDirectly() {
// res/xml/shortcuts.xml fires `amethyst:scanqr`. If this stops resolving, long-pressing the // res/xml/shortcuts.xml fires `amethyst:scanqr`. If this stops resolving, long-pressing the
@@ -0,0 +1,97 @@
/*
* 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.quartz.utils
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* The contract every actual of [UriParser] has to keep.
*
* The interesting case is the *opaque* URI -- a scheme followed by anything but `/`. The JVM and
* Android actual delegates to `java.net.URI`, which declares such a URI to have no query at all
* and folds `dlnwc?value=...` into one scheme-specific part, while the hand-rolled actuals look
* for `?` wherever it sits. That disagreement is what made a wallet-connect deep link report
* "that uri was invalid" on Android while the `//` spelling of the same link worked.
*/
class UriParserTest {
private val nwc = "nostr+walletconnect://abc?relay=wss://r.example&secret=s1"
@Test
fun readsTheQueryOfAnOpaqueUri() {
val parser = UriParser("amethyst+walletconnect:dlnwc?value=${encode(nwc)}")
assertEquals(nwc, parser.getQueryParameter("value")?.firstOrNull())
}
/** The spelling that already worked, so the rescue above cannot have broken it. */
@Test
fun readsTheQueryOfAHierarchicalUri() {
val parser = UriParser("amethyst+walletconnect://dlnwc?value=${encode(nwc)}")
assertEquals(nwc, parser.getQueryParameter("value")?.firstOrNull())
}
/** No scheme at all, which is how the callback arrives from some wallets. */
@Test
fun readsTheQueryOfASchemelessUri() {
val parser = UriParser("dlnwc?value=${encode(nwc)}")
assertEquals(nwc, parser.getQueryParameter("value")?.firstOrNull())
}
@Test
fun opaqueUriWithoutAQueryHasNoParameters() {
val parser = UriParser("cashu:sometoken")
assertNull(parser.getQueryParameter("value"))
assertEquals(emptySet(), parser.queryParameterNames())
}
@Test
fun namesEveryParameterOfAnOpaqueUri() {
val parser = UriParser("bunker:pubkeyhex?relay=wss%3A%2F%2Fr.example&secret=s1")
assertEquals(setOf("relay", "secret"), parser.queryParameterNames())
assertEquals("wss://r.example", parser.getQueryParameter("relay")?.firstOrNull())
assertEquals("s1", parser.getQueryParameter("secret")?.firstOrNull())
}
/** A fragment is parsed off an opaque URI by every actual already; keep it that way. */
@Test
fun keepsTheFragmentOfAnOpaqueUri() {
val parser = UriParser("scheme:thing?a=1#b=2")
assertEquals("1", parser.getQueryParameter("a")?.firstOrNull())
assertEquals(mapOf("b" to "2"), parser.fragments())
}
private fun encode(value: String) =
value
.replace("%", "%25")
.replace("+", "%2B")
.replace(":", "%3A")
.replace("/", "%2F")
.replace("?", "%3F")
.replace("&", "%26")
.replace("=", "%3D")
}
@@ -28,8 +28,28 @@ actual class UriParser actual constructor(
) { ) {
private val myUri = URI.create(uri) private val myUri = URI.create(uri)
/**
* The query string, including the one [java.net.URI] refuses to find.
*
* A URI whose scheme is followed by anything other than `/` is *opaque* to `java.net.URI`:
* `amethyst+walletconnect:dlnwc?value=...` has no query at all as far as it is concerned, and
* the whole `dlnwc?value=...` is one scheme-specific part. Every other actual of this class
* looks for `?` wherever it sits, so on JVM and Android alone the parameters of an opaque URI
* silently vanished -- which is how a perfectly valid wallet-connect deep link came back as
* "that uri was invalid", while the `//` spelling of the very same link worked.
*
* The fragment needs no such rescue: `java.net.URI` does parse `#...` off an opaque URI.
*/
private val rawQuery: String? =
myUri.rawQuery
?: myUri
.takeIf { it.isOpaque }
?.rawSchemeSpecificPart
?.substringAfter('?', "")
?.ifBlank { null }
private val queryParameters: Map<String, List<String>> by lazy { private val queryParameters: Map<String, List<String>> by lazy {
myUri.rawQuery?.ifBlank { null }?.let { query -> rawQuery?.ifBlank { null }?.let { query ->
val params = mutableMapOf<String, MutableList<String>>() val params = mutableMapOf<String, MutableList<String>>()
query.split('&').forEach { paramValue -> query.split('&').forEach { paramValue ->