From 57854e56dc200f3211e5c4a44b5f3a62795662b6 Mon Sep 17 00:00:00 2001 From: dece Date: Thu, 9 Dec 2021 01:44:52 +0100 Subject: [PATCH] UriUtils: add a bunch of URI utilities So we can now click on relative links, wow! Fast progress! --- app/build.gradle | 3 + .../comet/ExampleInstrumentedTest.kt | 24 ----- .../dev/lowrespalmtree/comet/UriUtilsTest.kt | 76 ++++++++++++++++ .../dev/lowrespalmtree/comet/MainActivity.kt | 6 ++ .../java/dev/lowrespalmtree/comet/UriUtils.kt | 88 +++++++++++++++++++ .../lowrespalmtree/comet/ExampleUnitTest.kt | 17 ---- 6 files changed, 173 insertions(+), 41 deletions(-) delete mode 100644 app/src/androidTest/java/dev/lowrespalmtree/comet/ExampleInstrumentedTest.kt create mode 100644 app/src/androidTest/java/dev/lowrespalmtree/comet/UriUtilsTest.kt create mode 100644 app/src/main/java/dev/lowrespalmtree/comet/UriUtils.kt delete mode 100644 app/src/test/java/dev/lowrespalmtree/comet/ExampleUnitTest.kt diff --git a/app/build.gradle b/app/build.gradle index de72644..cf38a41 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -18,6 +18,9 @@ android { buildFeatures { viewBinding true } + testOptions { + unitTests.returnDefaultValues = true + } buildTypes { release { minifyEnabled false diff --git a/app/src/androidTest/java/dev/lowrespalmtree/comet/ExampleInstrumentedTest.kt b/app/src/androidTest/java/dev/lowrespalmtree/comet/ExampleInstrumentedTest.kt deleted file mode 100644 index 7e382d2..0000000 --- a/app/src/androidTest/java/dev/lowrespalmtree/comet/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package dev.lowrespalmtree.comet - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("dev.lowrespalmtree.comet", appContext.packageName) - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/dev/lowrespalmtree/comet/UriUtilsTest.kt b/app/src/androidTest/java/dev/lowrespalmtree/comet/UriUtilsTest.kt new file mode 100644 index 0000000..059d40f --- /dev/null +++ b/app/src/androidTest/java/dev/lowrespalmtree/comet/UriUtilsTest.kt @@ -0,0 +1,76 @@ +package dev.lowrespalmtree.comet + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class UriUtilsTest { + @Test + fun joinUrls() { + assertEquals( + "gemini://dece.space/some-file.gmi", + joinUrls("gemini://dece.space/", "some-file.gmi").toString() + ) + assertEquals( + "gemini://dece.space/some-file.gmi", + joinUrls("gemini://dece.space/", "./some-file.gmi").toString() + ) + assertEquals( + "gemini://dece.space/some-file.gmi", + joinUrls("gemini://dece.space/dir1", "/some-file.gmi").toString() + ) + assertEquals( + "gemini://dece.space/dir1/other-file.gmi", + joinUrls("gemini://dece.space/dir1/file.gmi", "other-file.gmi").toString() + ) + assertEquals( + "gemini://dece.space/top-level.gmi", + joinUrls("gemini://dece.space/dir1/file.gmi", "../top-level.gmi").toString() + ) + assertEquals( + "s://hard/test/b/d/a.gmi", + joinUrls("s://hard/dir/a", "./../test/b/c/../d/e/f/../.././a.gmi").toString() + ) + } + + @Test + fun removeDotSegments() { + arrayOf( + Pair("index.gmi", "index.gmi"), + Pair("/index.gmi", "/index.gmi"), + Pair("./index.gmi", "index.gmi"), + Pair("/./index.gmi", "/index.gmi"), + Pair("/../index.gmi", "/index.gmi"), + Pair("/a/b/c/./../../g", "/a/g"), + Pair("mid/content=5/../6", "mid/6"), + Pair("../../../../g", "g") + ).forEach { (path, expected) -> + assertEquals(expected, removeDotSegments(path)) + } + } + + @Test + fun removeLastSegment() { + assertEquals("", removeLastSegment("")) + assertEquals("", removeLastSegment("/")) + assertEquals("", removeLastSegment("/a")) + assertEquals("/a", removeLastSegment("/a/")) + assertEquals("/a", removeLastSegment("/a/b")) + assertEquals("/a/b/c", removeLastSegment("/a/b/c/d")) + assertEquals("//", removeLastSegment("///")) + } + + @Test + fun popFirstSegment() { + assertEquals(Pair("", ""), popFirstSegment("")) + assertEquals(Pair("a", ""), popFirstSegment("a")) + assertEquals(Pair("/a", ""), popFirstSegment("/a")) + assertEquals(Pair("/a", "/"), popFirstSegment("/a/")) + assertEquals(Pair("/a", "/b"), popFirstSegment("/a/b")) + assertEquals(Pair("a", "/b"), popFirstSegment("a/b")) + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/lowrespalmtree/comet/MainActivity.kt b/app/src/main/java/dev/lowrespalmtree/comet/MainActivity.kt index 09e0747..d01636f 100644 --- a/app/src/main/java/dev/lowrespalmtree/comet/MainActivity.kt +++ b/app/src/main/java/dev/lowrespalmtree/comet/MainActivity.kt @@ -61,6 +61,12 @@ class MainActivity : AppCompatActivity(), ContentAdapter.ContentAdapterListen { private fun openUrl(url: String, base: String? = null) { var uri = Uri.parse(url) + if (!uri.isAbsolute) { + uri = if (!base.isNullOrEmpty()) joinUrls(base, url) else toGeminiUri(uri) + Log.d(TAG, "openUrl: '$url' - '$base' - '$uri'") + Log.d(TAG, "openUrl: ${uri.authority} - ${uri.path} - ${uri.query}") + binding.addressBar.setText(uri.toString()) + } when (uri.scheme) { "gemini" -> pageViewModel.sendGeminiRequest(uri) diff --git a/app/src/main/java/dev/lowrespalmtree/comet/UriUtils.kt b/app/src/main/java/dev/lowrespalmtree/comet/UriUtils.kt new file mode 100644 index 0000000..3f971da --- /dev/null +++ b/app/src/main/java/dev/lowrespalmtree/comet/UriUtils.kt @@ -0,0 +1,88 @@ +package dev.lowrespalmtree.comet + +import android.net.Uri +import android.util.Log + +/** + * Transform a relative URI to an absolute Gemini URI + * + * This is mostly to translate user-friendly URLs such as "medusae.space" into + * "gemini://medusae.space". This assumes that the Uri parsing put what the user probably intended + * as the hostname into the path field instead of the authority. Thus, it will NOT merely translate + * any absolute URI into a gemini-scheme URI. + */ +fun toGeminiUri(uri: Uri): Uri = + Uri.Builder() + .scheme("gemini") + .authority(uri.path) + .query(uri.query) + .fragment(uri.fragment) + .build() + +/** Return the URI obtained from considering the relative part of an URI wrt/ a base URI. */ +fun joinUrls(base: String, relative: String): Uri { + val baseUri = Uri.parse(base) + val relUri = Uri.parse(relative) + val newPath = removeDotSegments( + if (relative.startsWith("/")) { + relUri.path ?: "" + } else { + removeLastSegment( + baseUri.path ?: "" + ) + "/" + + (relUri.path ?: "") + } + ) + return Uri.Builder() + .scheme(baseUri.scheme) + .authority(baseUri.authority) + .path(newPath) + .query(relUri.query) + .fragment(relUri.fragment) + .build() +} + +/** Remove all the sneaky dot segments from the path. */ +internal fun removeDotSegments(path: String): String { + var output = "" + var slice = path + while (slice.isNotEmpty()) { + if (slice.startsWith("../")) { + slice = slice.substring(3) + } else if (slice.startsWith("./") || slice.startsWith("/./")) { + slice = slice.substring(2) + } else if (slice == "/.") { + slice = "/" + } else if (slice.startsWith("/../")) { + slice = "/" + slice.substring(4) + output = removeLastSegment(output) + } else if (slice == "/..") { + slice = "/" + output = removeLastSegment(output) + } else if (slice == "." || slice == "..") { + slice = "" + } else { + val (firstSegment, remaining) = popFirstSegment(slice) + output += firstSegment + slice = remaining + } + } + return output +} + +/** Remove the last segment of that path, including preceding "/" if any. */ +internal fun removeLastSegment(path: String): String { + val lastSlashIndex = path.indexOfLast { it == '/' } + return if (lastSlashIndex > -1) path.slice(0 until lastSlashIndex) else path +} + +/** Return first segment and the rest. */ +internal fun popFirstSegment(path: String): Pair { + if (path.isEmpty()) + return Pair(path, "") + var nextSlash = path.substring(1).indexOf("/") + if (nextSlash == -1) + return Pair(path, "") + nextSlash++ + return Pair(path.substring(0, nextSlash), path.substring(nextSlash)) +} \ No newline at end of file diff --git a/app/src/test/java/dev/lowrespalmtree/comet/ExampleUnitTest.kt b/app/src/test/java/dev/lowrespalmtree/comet/ExampleUnitTest.kt deleted file mode 100644 index c3d2630..0000000 --- a/app/src/test/java/dev/lowrespalmtree/comet/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package dev.lowrespalmtree.comet - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file