-
Notifications
You must be signed in to change notification settings - Fork 0
Parse and linkify NomadNet page addresses in chat #38
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
90 changes: 90 additions & 0 deletions
90
app/src/main/kotlin/tech/torlando/eridanus/ui/screens/ChatLinks.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| // SPDX-License-Identifier: MPL-2.0 | ||
|
|
||
| package tech.torlando.eridanus.ui.screens | ||
|
|
||
| /** | ||
| * Which kind of link a detected span is, so the renderer can wire the right tap | ||
| * behaviour: a [WEB] link opens in the system browser (via `LinkAnnotation.Url` | ||
| * → `LocalUriHandler`), while a [NOMADNET] address is handed to an installed | ||
| * NomadNet-capable app through an `ACTION_VIEW` intent. | ||
| */ | ||
| internal enum class ChatLinkKind { WEB, NOMADNET } | ||
|
|
||
| /** | ||
| * A detected link span in a message body: the [range] (inclusive indices into | ||
| * the source text), the matched [text], and its [kind]. | ||
| */ | ||
| internal data class ChatLink(val range: IntRange, val text: String, val kind: ChatLinkKind) | ||
|
|
||
| /** | ||
| * http(s) URLs. An explicit scheme is required so a bare domain — including the | ||
| * `.mu` tail of a NomadNet page path — is not mistaken for a web link. | ||
| */ | ||
| internal val WEB_URL_REGEX = Regex("""https?://[^\s]+""", RegexOption.IGNORE_CASE) | ||
|
|
||
| /** | ||
| * A bare NomadNet page address: a 32-hex destination hash, then `:/`, then the | ||
| * page path — e.g. `9ce92808be498e9e05590ff27cbfdfe4:/page/index.mu`. There is | ||
| * **no** `nomadnetwork://` scheme on the wire; the address is written bare. The | ||
| * path run also captures any trailing micron field/query data appended after a | ||
| * backtick (`` `field=value|other=value ``), since `` ` ``, `=` and `|` are all | ||
| * permitted path characters. | ||
| * | ||
| * Deliberate boundaries: | ||
| * - the leading `(?<![0-9a-fA-F])` stops a 32-char window from matching inside a | ||
| * longer hex run; | ||
| * - a bare 32-hex hash with **no** `:/path` is not matched — those are commonly | ||
| * pasted identity/destination hashes, not page links; | ||
| * - the path class excludes `)` `]` and the trailing `(?<![.,;:])` drops a | ||
| * sentence terminator the address bumps up against, so `(see …index.mu).` → | ||
| * `…index.mu`. | ||
| * | ||
| * Only fixed-length lookbehind is used and the character classes are spelled out | ||
| * rather than relying on `\w`/`(?U)`, so this compiles and behaves identically | ||
| * on Android's ICU regex engine and the desktop JVM (see [selfMentionRanges] | ||
| * for the same Android-regex caveat). | ||
| */ | ||
| internal val NOMADNET_ADDRESS = | ||
| Regex("""(?<![0-9a-fA-F])[0-9a-fA-F]{32}:/[^\s,;!?)\]]+(?<![.,;:])""") | ||
|
|
||
| /** | ||
| * Detect all web and NomadNet link spans in [text], resolving overlaps so the | ||
| * returned spans never collide. | ||
| * | ||
| * Resolution: earliest start wins; ties break toward the longer span. This | ||
| * gives a bare NomadNet address precedence over a stray `http`-shaped fragment | ||
| * nested inside its path, while a genuine `http://…` that merely *contains* a | ||
| * hash still wins because it starts first. | ||
| * | ||
| * Returned spans are ordered by start index. | ||
| */ | ||
| internal fun detectChatLinks(text: String): List<ChatLink> { | ||
| val candidates = ArrayList<ChatLink>() | ||
| NOMADNET_ADDRESS.findAll(text).forEach { | ||
| candidates += ChatLink(it.range, it.value, ChatLinkKind.NOMADNET) | ||
| } | ||
| WEB_URL_REGEX.findAll(text).forEach { | ||
| candidates += ChatLink(it.range, it.value, ChatLinkKind.WEB) | ||
| } | ||
| if (candidates.size <= 1) return candidates | ||
| // Earliest start first; on equal start the longer span first. Then greedily | ||
| // keep a candidate only if it starts past the last one we kept. | ||
| candidates.sortWith(compareBy({ it.range.first }, { -it.range.last })) | ||
| val resolved = ArrayList<ChatLink>(candidates.size) | ||
| var lastEnd = -1 | ||
| for (c in candidates) { | ||
| if (c.range.first <= lastEnd) continue | ||
| resolved += c | ||
| lastEnd = c.range.last | ||
| } | ||
| return resolved | ||
| } | ||
|
|
||
| /** | ||
| * The browsable URI for a bare NomadNet [address] (as matched by | ||
| * [NOMADNET_ADDRESS]): the `nomadnetwork://` scheme is prepended so an | ||
| * `ACTION_VIEW` intent resolves to an installed NomadNet-capable app. The | ||
| * address — hash, path, and any backtick field tail — is passed through | ||
| * verbatim; the receiving app does its own parsing. | ||
| */ | ||
| internal fun toNomadNetUri(address: String): String = "nomadnetwork://$address" | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
96 changes: 96 additions & 0 deletions
96
app/src/test/kotlin/tech/torlando/eridanus/ui/screens/ChatLinksTest.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| // SPDX-License-Identifier: MPL-2.0 | ||
|
|
||
| package tech.torlando.eridanus.ui.screens | ||
|
|
||
| import org.junit.Assert.assertEquals | ||
| import org.junit.Test | ||
|
|
||
| /** | ||
| * Pins down link detection in message bodies: bare NomadNet page addresses | ||
| * (`<32-hex>:/path.mu`, optionally with a backtick field tail) are linkified | ||
| * whole and kept distinct from http(s) URLs, while bare hashes and trailing | ||
| * punctuation are deliberately left out. | ||
| */ | ||
| class ChatLinksTest { | ||
| private val hash = "9ce92808be498e9e05590ff27cbfdfe4" | ||
|
|
||
| /** Matched substrings, in order. */ | ||
| private fun texts(text: String): List<String> = | ||
| detectChatLinks(text).map { it.text } | ||
|
|
||
| /** Matched (kind, substring) pairs, in order. */ | ||
| private fun kinds(text: String): List<Pair<ChatLinkKind, String>> = | ||
| detectChatLinks(text).map { it.kind to it.text } | ||
|
|
||
| @Test | ||
| fun bareNomadNetAddressIsLinkifiedWhole() { | ||
| val text = "$hash:/page/forum/register.mu" | ||
| assertEquals(listOf(ChatLinkKind.NOMADNET to text), kinds(text)) | ||
| } | ||
|
|
||
| @Test | ||
| fun nomadNetAddressInsideSurroundingTextIsLinkifiedWhole() { | ||
| val addr = "$hash:/page/index.mu" | ||
| assertEquals(listOf(addr), texts("Verify at $addr to continue")) | ||
| } | ||
|
|
||
| @Test | ||
| fun backtickFieldTailIsCapturedWithTheAddress() { | ||
| // The on-wire NomadNet link form: no scheme, hash:/path, then a | ||
| // backtick-delimited field/query tail with `=` and `|` separators. | ||
| val addr = "$hash:/page/index.mu`field1=value|field2=value" | ||
| assertEquals(listOf(ChatLinkKind.NOMADNET to addr), kinds("open $addr now")) | ||
| } | ||
|
|
||
| @Test | ||
| fun trailingClosingParenIsExcluded() { | ||
| val addr = "$hash:/page/forum/register.mu" | ||
| assertEquals(listOf(addr), texts("(see $addr)")) | ||
| } | ||
|
|
||
| @Test | ||
| fun trailingSentencePunctuationIsExcluded() { | ||
| val addr = "$hash:/page/index.mu" | ||
| assertEquals(listOf(addr), texts("go to $addr.")) | ||
| } | ||
|
|
||
| @Test | ||
| fun bare32HexHashWithoutPathIsNotLinkified() { | ||
| // Commonly a pasted identity/destination hash — not a page link. | ||
| assertEquals(emptyList<String>(), texts("my address is $hash ok")) | ||
| } | ||
|
|
||
| @Test | ||
| fun hexRunLongerThan32IsNotMatched() { | ||
| // The leading lookbehind keeps a 32-char window from matching inside a | ||
| // longer hex run, so a 40-hex blob with a path is not a NomadNet link. | ||
| val long = hash + "0123456789abcdef0123" | ||
| assertEquals(emptyList<String>(), texts("$long:/page/index.mu")) | ||
| } | ||
|
|
||
| @Test | ||
| fun webUrlAndNomadNetAddressCoexistAsTwoSpans() { | ||
| val addr = "$hash:/page/index.mu" | ||
| assertEquals( | ||
| listOf( | ||
| ChatLinkKind.WEB to "https://example.com", | ||
| ChatLinkKind.NOMADNET to addr, | ||
| ), | ||
| kinds("web https://example.com and node $addr"), | ||
| ) | ||
| } | ||
|
|
||
| @Test | ||
| fun httpUrlContainingAHashStaysOneWebLink() { | ||
| // A real http(s) URL wins by starting first; it isn't split into a | ||
| // separate NomadNet span even though its tail looks address-shaped. | ||
| val url = "https://example.com/$hash:/page/index.mu" | ||
| assertEquals(listOf(ChatLinkKind.WEB to url), kinds(url)) | ||
| } | ||
|
|
||
| @Test | ||
| fun toNomadNetUriPrependsScheme() { | ||
| val addr = "$hash:/page/index.mu`a=b" | ||
| assertEquals("nomadnetwork://$addr", toNomadNetUri(addr)) | ||
| } | ||
| } | ||
|
torlando-tech marked this conversation as resolved.
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.