20x Faster Rfc3986Normalizer and way less objects being created.

This commit is contained in:
Vitor Pamplona
2026-03-26 19:04:18 -04:00
parent 7c8d44559e
commit 75a8e461ad
6 changed files with 322 additions and 84 deletions
@@ -23,13 +23,11 @@ package com.vitorpamplona.quartz.benchmark
import androidx.benchmark.junit4.BenchmarkRule
import androidx.benchmark.junit4.measureRepeated
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.utils.Rfc3986
import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector
import io.kotlingeekdev.urireference.URIReference
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.toString
/**
* Benchmark, which will execute on an Android device.
@@ -38,28 +36,14 @@ import kotlin.toString
* result. Modify your code to see how it affects performance.
*/
@RunWith(AndroidJUnit4::class)
class RelayUrlNormalizerBenchmark {
class Rfc3986UrlNormalizerBenchmark {
@get:Rule
val benchmarkRule = BenchmarkRule()
@Test
fun normalize() {
benchmarkRule.measureRepeated {
"wss://relay.damus.io".normalizeRelayUrl()
}
}
@Test
fun normalizeDirect() {
benchmarkRule.measureRepeated {
URIReference.parse("wss://nostr.mom/").normalize().toString()
}
}
@Test
fun parseUriReference() {
benchmarkRule.measureRepeated {
URIReference.parse("wss://nostr.mom/").toString()
Rfc3986.normalize("wss://relay.damus.io")
}
}
-2
View File
@@ -54,7 +54,6 @@ torAndroid = "0.4.9.5.1"
translate = "17.0.3"
jetbrainsCompose = "1.10.3"
unifiedpush = "3.0.10"
uriReferenceKmp = "1.0"
vico-charts-compose = "3.0.3"
zelory = "3.0.1"
zoomable = "2.11.1"
@@ -124,7 +123,6 @@ coil-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", versio
coil-video = { group = "io.coil-kt.coil3", name = "coil-video", version.ref = "coil" }
commons-imaging = { group = "org.apache.commons", name = "commons-imaging", version.ref = "commonsImaging" }
slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" }
uri-reference-kmp = { module = "io.github.kotlingeekdev:uri-reference-kmp", version.ref = "uriReferenceKmp" }
vlcj = { group = "uk.co.caprica", name = "vlcj", version.ref = "vlcj" }
dev-whyoleg-cryptography-provider-apple-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "devWhyolegCryptography" }
drfonfon-geohash = { group = "com.github.drfonfon", name = "android-kotlin-geohash", version.ref = "androidKotlinGeohash" }
-7
View File
@@ -118,9 +118,6 @@ kotlin {
// SQLite KMP driver for event store
api(libs.androidx.sqlite)
implementation(libs.androidx.sqlite.bundled)
// RFC3986 library(normalizes URLs)
api(libs.uri.reference.kmp)
}
}
@@ -170,7 +167,6 @@ kotlin {
dependencies {
// Bitcoin secp256k1 bindings
implementation(libs.secp256k1.kmp.jni.jvm)
}
}
@@ -192,7 +188,6 @@ kotlin {
// Bitcoin secp256k1 bindings to Android
api(libs.secp256k1.kmp.jni.android)
}
}
@@ -204,7 +199,6 @@ kotlin {
// Bitcoin secp256k1 bindings
implementation(libs.secp256k1.kmp.jni.jvm)
// SQLite bundled driver for Host tests
implementation(libs.androidx.sqlite.bundled.jvm)
}
@@ -222,7 +216,6 @@ kotlin {
// Bitcoin secp256k1 bindings to Android
api(libs.secp256k1.kmp.jni.android)
}
}
@@ -20,47 +20,16 @@
*/
package com.vitorpamplona.quartz.utils
import io.kotlingeekdev.urireference.URIReference
import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector
object Rfc3986 {
fun normalize(uri: String): String = URIReference.parse(uri).normalize().toString()
fun parse(url: String) = UrlDetector(url).detect()[0]
fun isValidUrl(url: String): Boolean =
runCatching {
URIReference.parse(url)
}.isSuccess
fun normalize(uri: String): String = parse(uri).fullUrl
fun normalizeAndRemoveFragment(url: String): String =
URIReference
.parse(url)
.normalize()!!
.toStringNoFragment()
.internIfPossible()
fun isValidUrl(url: String): Boolean = runCatching { parse(url) }.isSuccess
fun host(url: String): String =
URIReference
.parse(url)
.host
?.value
.toString()
}
fun URIReference.toStringSchemeHost(): String {
val sb = StringBuilder()
if (scheme != null) sb.append(scheme).append(":")
if (authority != null) sb.append("//").append(authority.toString())
return sb.toString()
}
fun URIReference.toStringNoFragment(): String {
val sb = StringBuilder()
if (scheme != null) sb.append(scheme).append(":")
if (host != null) sb.append("//").append(host.toString())
if (path != null) sb.append(path)
if (query != null) sb.append("?").append(query)
return sb.toString()
fun normalizeAndRemoveFragment(url: String): String = parse(url).fullUrlWithoutFragment.internIfPossible()
fun host(url: String): String = parse(url).host
}
@@ -98,9 +98,9 @@ class Url(
if (index != -1) {
_scheme = _scheme!!.substring(0, index)
}
_scheme = _scheme!!.lowercase()
} else if (!originalUrl.startsWith("//")) {
_scheme =
DEFAULT_SCHEME
_scheme = DEFAULT_SCHEME
}
}
return _scheme ?: ""
@@ -125,7 +125,10 @@ class Url(
val host: String
get() {
if (this.rawHost == null) {
this.rawHost = getPart(UrlPart.HOST)
this.rawHost =
getPart(UrlPart.HOST)?.let {
lowercaseLiteralChars(normalizeComponent(it))
}
if (exists(UrlPart.PORT)) {
this.rawHost =
rawHost?.let {
@@ -142,8 +145,7 @@ class Url(
val port: Int
get() {
if (_port == 0) {
val portString =
getPart(UrlPart.PORT)
val portString = getPart(UrlPart.PORT)
if (!portString.isNullOrEmpty()) {
_port = portString.toIntOrNull() ?: -1
} else {
@@ -156,14 +158,9 @@ class Url(
val path: String?
get() {
if (this.rawPath == null) {
this.rawPath =
if (exists(UrlPart.PATH)) {
getPart(
UrlPart.PATH,
)
} else {
"/"
}
this.rawPath = getPart(UrlPart.PATH)?.let {
normalizeComponent(removeDotSegments(it))
} ?: "/"
}
return this.rawPath
}
@@ -171,7 +168,10 @@ class Url(
val query: String
get() {
if (_query == null) {
_query = getPart(UrlPart.QUERY)
_query =
getPart(UrlPart.QUERY)?.let {
normalizeComponent(it)
} ?: ""
}
return _query ?: ""
}
@@ -179,7 +179,9 @@ class Url(
val fragment: String
get() {
if (_fragment == null) {
_fragment = getPart(UrlPart.FRAGMENT)
_fragment = getPart(UrlPart.FRAGMENT)?.let {
normalizeComponent(it)
} ?: ""
}
return _fragment ?: ""
}
@@ -190,10 +192,10 @@ class Url(
val usernamePasswordParts: List<String> =
usernamePassword.substring(0, usernamePassword.length - 1).split(":")
if (usernamePasswordParts.size == 1) {
_username = usernamePasswordParts[0]
_username = normalizeComponent(usernamePasswordParts[0])
} else if (usernamePasswordParts.size == 2) {
_username = usernamePasswordParts[0]
_password = usernamePasswordParts[1]
_username = normalizeComponent(usernamePasswordParts[0])
_password = normalizeComponent(usernamePasswordParts[1])
}
}
}
@@ -242,7 +244,206 @@ class Url(
}
}
/**
* Removes dot segments from the given path as stated in
* ["RFC 3986, 5.2.4. Remove Dot Segments"](https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4).
*
* @param path
* The path from which dot segments are to be removed.
*
* @return
* The path from which dot segments are removed.
*/
fun removeDotSegments(path: String): String {
// Initialize the input with the no-appended path components and the output
// with the empty string.
var input = path
var output = ""
// While the input is not empty, loop the following steps.
while (input.isNotEmpty()) {
// If the input begins with a prefix of "../" or "./", then
// remove that prefix from the input;
if (DOT_DOT_SLASH.find(input) != null) {
input = DOT_DOT_SLASH.replaceFirst(input, "")
continue
}
// If the input begins with a prefix of "/./" or "/.", where
// "." is a complete path segment, then replace that prefix
// with "/" in the input.
if (SLASH_DOT_SLASH.find(input) != null) {
input = SLASH_DOT_SLASH.replaceFirst(input, "/")
continue
}
// If the input begins with a prefix of "/../" or "/..",
// where ".." is a complete path segment, then replace that
// prefix with "/" in the input and remove the last segment
// and its preceding "/" (if any) from the output.
if (SLASH_DOT_DOT_SLASH.find(input) != null) {
input = SLASH_DOT_DOT_SLASH.replaceFirst(input, "/")
output = dropLastSegment(output, true)
continue
}
// If the input consists only of "." or "..", then remove
// that from the input.
if (DOT_OR_DOT_DOT.find(input) != null) {
input = DOT_OR_DOT_DOT.replaceFirst(input, "")
continue
}
// Move the first path segment in the input buffer to the
// end of the output, including the initial "/" character
// (if any) and any subsequent characters up to, but not
// including, the next "/" character or the end of the input.
val matchResult = MOVE_REGEX.find(input)
if (matchResult != null) {
input = matchResult.groups["remaining"]!!.value
output += matchResult.groups["firstsegment"]!!.value
continue
}
}
return output
}
/**
* Drops the last segment (= characters after the last slash) of a path and
* optionally the last slash. If the path doesn't contain slash, an empty string
* is returned.
*
* @param path
* The path.
*
* @param dropLastSlash
* Whether or not to drop the last slash if present.
*
* @return The path from which the last segment is removed.
*/
fun dropLastSegment(
path: String,
dropLastSlash: Boolean,
): String {
// The regular expression for the target.
val m = if (dropLastSlash) DROP_LAST_SLASH_REGEX else DROP_LAST_SEGMENT_REGEX
// Find the target. (Any inputs matches the pattern.)
m.find(path)
// Drop the target.
return m.replaceFirst(path, "")
}
/**
* Unreserved characters per RFC 3986 §2.3:
* ALPHA / DIGIT / "-" / "." / "_" / "~"
*/
private fun isUnreserved(c: Char): Boolean = c.isLetter() || c.isDigit() || c == '-' || c == '.' || c == '_' || c == '~'
/**
* Normalize percent-encoded triplets in a single URI component string.
*
* For each %XX triplet:
* - If the decoded byte is an unreserved ASCII character → decode it
* - Otherwise → keep encoded but uppercase the hex digits
*
* Non-ASCII bytes (e.g. UTF-8 multi-byte sequences) are left encoded
* since they cannot be unreserved characters.
*/
fun normalizeComponent(input: String): String {
val sb = StringBuilder(input.length)
var i = 0
while (i < input.length) {
val c = input[i]
if (c == '%' && i + 2 < input.length) {
val hex = input.substring(i + 1, i + 3)
// Validate that both characters are valid hex digits
if (hex.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' }) {
val byteValue = hex.toInt(16)
// Only consider single-byte ASCII values for potential decoding
if (byteValue < 0x80) {
val decoded = byteValue.toChar()
if (isUnreserved(decoded)) {
// Decode: replace %XX with the literal character
sb.append(decoded)
i += 3
continue
}
}
// Keep encoded, but uppercase the hex digits
sb.append('%')
sb.append(hex.uppercase())
i += 3
continue
}
}
// Regular character — pass through as-is
sb.append(c)
i++
}
return sb.toString()
}
/**
* Lowercase only the literal (non-percent-encoded) characters in a string.
* Percent-encoded triplets are left untouched so their uppercased hex digits
* are not inadvertently lowercased.
*/
private fun lowercaseLiteralChars(input: String): String {
val sb = StringBuilder(input.length)
var i = 0
while (i < input.length) {
if (input[i] == '%' && i + 2 < input.length) {
sb.append(input[i])
sb.append(input[i + 1])
sb.append(input[i + 2])
i += 3
} else {
sb.append(input[i].lowercaseChar())
i++
}
}
return sb.toString()
}
companion object {
val DROP_LAST_SLASH_REGEX = Regex("\\/?[^/]*$")
val DROP_LAST_SEGMENT_REGEX = Regex("[^/]*$")
// If the input begins with a prefix of "../" or "./", then
// remove that prefix from the input;
val DOT_DOT_SLASH = Regex("^\\.?\\./")
// If the input begins with a prefix of "/./" or "/.", where
// "." is a complete path segment, then replace that prefix
// with "/" in the input.
val SLASH_DOT_SLASH = Regex("^\\/\\.(\\/|$)")
// If the input begins with a prefix of "/../" or "/..",
// where ".." is a complete path segment, then replace that
// prefix with "/" in the input and remove the last segment
// and its preceding "/" (if any) from the output.
val SLASH_DOT_DOT_SLASH = Regex("^\\/\\.\\.(\\/|$)")
// If the input consists only of "." or "..", then remove
// that from the input.
val DOT_OR_DOT_DOT = Regex("^\\.?\\.$")
// Move the first path segment in the input buffer to the
// end of the output, including the initial "/" character
// (if any) and any subsequent characters up to, but not
// including, the next "/" character or the end of the input.
val MOVE_REGEX = Regex("^(?<firstsegment>\\/?[^/]*)(?<remaining>.*)$")
private const val DEFAULT_SCHEME = "https"
private val SCHEME_PORT_MAP: Map<String, Int> =
mapOf(
@@ -0,0 +1,93 @@
/*
* 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.urldetector
import com.vitorpamplona.quartz.utils.Rfc3986
import kotlin.test.Test
import kotlin.test.assertEquals
class URIReferenceNormalizerTest {
@Test
fun test_normalize() {
val uriRef1 = Rfc3986.normalize("hTTp://example.com/")
assertEquals("http://example.com/", uriRef1)
val uriRef2 = Rfc3986.normalize("http://example.com/")
assertEquals("http://example.com/", uriRef2)
val uriRef3 = Rfc3986.normalize("http://%75ser@example.com/")
assertEquals("http://user@example.com/", uriRef3)
val uriRef4 = Rfc3986.normalize("http://%e3%83%a6%e3%83%bc%e3%82%b6%e3%83%bc@example.com/")
assertEquals("http://%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC@example.com/", uriRef4)
val uriRef5 = Rfc3986.normalize("http://%65%78%61%6D%70%6C%65.com/")
assertEquals("http://example.com/", uriRef5)
val uriRef6 = Rfc3986.normalize("http://%e4%be%8b.com/")
assertEquals("http://%E4%BE%8B.com/", uriRef6)
val uriRef7 = Rfc3986.normalize("http://LOCALhost/")
assertEquals("http://localhost/", uriRef7)
val uriRef8 = Rfc3986.normalize("http://example.com")
assertEquals("http://example.com/", uriRef8)
val uriRef9 = Rfc3986.normalize("http://example.com/%61/%62/%63/")
assertEquals("http://example.com/a/b/c/", uriRef9)
val uriRef10 = Rfc3986.normalize("http://example.com/%e3%83%91%e3%82%b9/")
assertEquals("http://example.com/%E3%83%91%E3%82%B9/", uriRef10)
val uriRef11 = Rfc3986.normalize("http://example.com/a/b/c/../d/")
assertEquals("http://example.com/a/b/d/", uriRef11)
val uriRef12 = Rfc3986.normalize("http://example.com:80/")
assertEquals("http://example.com/", uriRef12)
}
@Test
fun moreTests() {
// From our conversation
assertEquals("http://%E4%BE%8B.com/", Rfc3986.normalize("http://%e4%be%8b.com/"))
// Unreserved chars that should be decoded
assertEquals("http://example.com/~user/ABC", Rfc3986.normalize("http://example.com/%7euser/%41BC"))
// Reserved chars that must NOT be decoded
assertEquals("http://example.com/path%2Fsegment?key%3Dvalue", Rfc3986.normalize("http://example.com/path%2Fsegment?key%3Dvalue"))
// Mixed: some decodable, some not, lowercase hex
assertEquals("http://example.com/~%2Fa%3F", Rfc3986.normalize("http://EXAMPLE.COM/%7e%2f%61%3F"))
// Userinfo
assertEquals("http://user%40name@example.com/", Rfc3986.normalize("http://user%40name@example.com/"))
// With port and query and fragment
assertEquals("https://example.com:8080/foo~bar?a%3D1&b%2Bc#frag%23ment", Rfc3986.normalize("https://Example.COM:8080/foo%7ebar?a%3D1&b%2Bc#frag%23ment"))
// IPv6
assertEquals("http://[::1]:8080/path", Rfc3986.normalize("http://[::1]:8080/path"))
// Already normalized
assertEquals("http://example.com/~user", Rfc3986.normalize("http://example.com/~user"))
}
}