From 0ccb9b23ccb1dd9901d5d3f41cb0731ba3a94be2 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Sun, 9 Aug 2026 03:51:38 +0300 Subject: [PATCH 1/7] Linkify URLs whose host is a bracketed IPv6 address Patterns.WEB_URL, which LinkifyCompat uses to find links, has no production for a bracketed IPv6 authority, so a URL such as http://[2001:db8::1]:8008/path was never turned into a link. Worse, the phone number matcher claimed the digit runs inside the address, leaving a tel: link over part of it. LinkifyHelper now runs its own pass for those URLs. It requires both a scheme and the brackets, so array syntax like values[0]:1 is not matched, and it runs after LinkifyCompat.addLinks, which strips any span set before it, but before the pre-existing spans are restored, so real anchors and pill spans keep their precedence. Any span Linkify left inside the address is evicted, and the same trailing punctuation trimming as the other links is applied. Relates to https://github.com/element-hq/element-x-android/issues/2614 --- .../androidutils/text/LinkifyHelper.kt | 27 ++++++++++++++ .../androidutils/text/LinkifierHelperTest.kt | 37 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/LinkifyHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/LinkifyHelper.kt index 441f819546..03dde18006 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/LinkifyHelper.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/LinkifyHelper.kt @@ -23,6 +23,12 @@ import timber.log.Timber * It also checks the linkified results to make sure URLs spans are not including trailing punctuation. */ object LinkifyHelper { + /** + * Matches a URL whose authority is a bracketed IPv6 address, i.e. `http://[2001:db8::1]:8008/path`. + * A scheme and the brackets are both required, so array syntax such as `values[0]:1` is not matched. + */ + private val IPV6_URL_REGEX = Regex("""(?i)\b(?:https?|ftp)://\[[0-9a-f:.]+(?:%25[0-9a-z._-]+)?](?::\d{1,5})?(?:/\S*)?""") + fun linkify( text: CharSequence, @LinkifyCompat.LinkifyMask linkifyMask: Int = Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES, @@ -67,6 +73,27 @@ object LinkifyHelper { } } + // Patterns.WEB_URL, which LinkifyCompat uses, has no production for a bracketed IPv6 + // authority, so such a URL is never linkified — and worse, the phone-number matcher claims + // the digit runs inside the address. Add the links here, after addLinks (which strips any + // span set before it) and before the old spans are restored, so real and pill + // spans still win. See https://github.com/element-hq/element-x-android/issues/2614 + for (match in IPV6_URL_REGEX.findAll(spannable)) { + val start = match.range.first + val end = match.range.last + 1 + val newEnd = runCatchingExceptions { + adjustLinkifiedUrlSpanEndIndex(spannable, start, end) + }.onFailure { + Timber.e(it, "Failed to adjust end index for IPv6 link span") + }.getOrNull() ?: end + + // Evict whatever Linkify put inside the address, typically a tel: span. + for (span in spannable.getSpans(start, newEnd)) { + spannable.removeSpan(span) + } + spannable.setSpan(URLSpan(spannable.substring(start, newEnd)), start, newEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + // Restore old spans, remove new ones if there is a conflict for ((urlSpan, location) in oldURLSpans) { val (start, end) = location diff --git a/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/text/LinkifierHelperTest.kt b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/text/LinkifierHelperTest.kt index bb7a272908..47e6b1a933 100644 --- a/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/text/LinkifierHelperTest.kt +++ b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/text/LinkifierHelperTest.kt @@ -36,6 +36,43 @@ class LinkifierHelperTest : RobolectricTest() { assertThat(urlSpans.first().url).isEqualTo("https://matrix.org") } + @Test + fun `linkification finds ipv6 URL`() { + val text = "A url http://[2001:db8::1]:8008/path" + val result = LinkifyHelper.linkify(text) + val urlSpans = result.toSpannable().getSpans() + assertThat(urlSpans.size).isEqualTo(1) + assertThat(urlSpans.first().url).isEqualTo("http://[2001:db8::1]:8008/path") + } + + @Test + @Config(sdk = [30]) + fun `linkification of an ipv6 URL does not leave a phone number span`() { + shadowOf(newInstanceOf(TelephonyManager::class.java)).setSimCountryIso("DE") + val text = "A url http://[2001:db8::1]:8008/path" + val result = LinkifyHelper.linkify(text) + val urlSpans = result.toSpannable().getSpans() + assertThat(urlSpans.size).isEqualTo(1) + assertThat(urlSpans.first().url).isEqualTo("http://[2001:db8::1]:8008/path") + } + + @Test + fun `linkification of an ipv6 URL trims trailing punctuation`() { + val text = "A url http://[::1]/x." + val result = LinkifyHelper.linkify(text) + val urlSpans = result.toSpannable().getSpans() + assertThat(urlSpans.size).isEqualTo(1) + assertThat(urlSpans.first().url).isEqualTo("http://[::1]/x") + } + + @Test + fun `linkification ignores bracketed text which is not a URL`() { + val text = "An array values[0]:1 and a bare [::1]" + val result = LinkifyHelper.linkify(text) + val urlSpans = result.toSpannable().getSpans() + assertThat(urlSpans).isEmpty() + } + @Test fun `linkification finds partial URL`() { val text = "A partial url matrix.org/test" From e5337b42e09695e636cc81ed543ad6e74bf51d16 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:44:14 +0300 Subject: [PATCH 2/7] Validate the bracketed authority with a URI parser instead of a regex Review feedback: the previous pattern hardcoded http, https and ftp and guessed at the address grammar. Candidates are now found by scheme and handed to java.net.URI, which accepts any scheme and rejects an authority that is not a real IPv6 literal, an unclosed bracket, or array syntax such as values[0]:1. Also drops the explanatory comments. --- .../androidutils/text/LinkifyHelper.kt | 27 +++++++++---------- .../androidutils/text/LinkifierHelperTest.kt | 17 ++++++++++++ 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/LinkifyHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/LinkifyHelper.kt index 03dde18006..28d856ce1f 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/LinkifyHelper.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/LinkifyHelper.kt @@ -16,6 +16,7 @@ import androidx.core.text.toSpannable import androidx.core.text.util.LinkifyCompat import io.element.android.libraries.core.extensions.runCatchingExceptions import timber.log.Timber +import java.net.URI /** * Helper class to linkify text while preserving existing URL spans. @@ -23,11 +24,7 @@ import timber.log.Timber * It also checks the linkified results to make sure URLs spans are not including trailing punctuation. */ object LinkifyHelper { - /** - * Matches a URL whose authority is a bracketed IPv6 address, i.e. `http://[2001:db8::1]:8008/path`. - * A scheme and the brackets are both required, so array syntax such as `values[0]:1` is not matched. - */ - private val IPV6_URL_REGEX = Regex("""(?i)\b(?:https?|ftp)://\[[0-9a-f:.]+(?:%25[0-9a-z._-]+)?](?::\d{1,5})?(?:/\S*)?""") + private val BRACKETED_AUTHORITY_URL_REGEX = Regex("""(? and pill - // spans still win. See https://github.com/element-hq/element-x-android/issues/2614 - for (match in IPV6_URL_REGEX.findAll(spannable)) { + for (match in BRACKETED_AUTHORITY_URL_REGEX.findAll(spannable)) { val start = match.range.first val end = match.range.last + 1 val newEnd = runCatchingExceptions { adjustLinkifiedUrlSpanEndIndex(spannable, start, end) }.onFailure { - Timber.e(it, "Failed to adjust end index for IPv6 link span") + Timber.e(it, "Failed to adjust end index for link span") }.getOrNull() ?: end - // Evict whatever Linkify put inside the address, typically a tel: span. + val url = spannable.substring(start, newEnd) + if (!url.hasBracketedHost()) continue + for (span in spannable.getSpans(start, newEnd)) { spannable.removeSpan(span) } - spannable.setSpan(URLSpan(spannable.substring(start, newEnd)), start, newEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + spannable.setSpan(URLSpan(url), start, newEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) } // Restore old spans, remove new ones if there is a conflict @@ -109,6 +103,11 @@ object LinkifyHelper { return spannable } + private fun String.hasBracketedHost(): Boolean { + val host = runCatchingExceptions { URI(this) }.getOrNull()?.host ?: return false + return host.startsWith('[') && host.endsWith(']') + } + private fun adjustLinkifiedUrlSpanEndIndex(spannable: Spannable, start: Int, end: Int): Int { var end = end diff --git a/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/text/LinkifierHelperTest.kt b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/text/LinkifierHelperTest.kt index 47e6b1a933..9d7e622ca6 100644 --- a/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/text/LinkifierHelperTest.kt +++ b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/text/LinkifierHelperTest.kt @@ -65,6 +65,23 @@ class LinkifierHelperTest : RobolectricTest() { assertThat(urlSpans.first().url).isEqualTo("http://[::1]/x") } + @Test + fun `linkification finds an ipv6 URL of any scheme`() { + val text = "A url ssh://[2001:db8::1]/path" + val result = LinkifyHelper.linkify(text) + val urlSpans = result.toSpannable().getSpans() + assertThat(urlSpans.size).isEqualTo(1) + assertThat(urlSpans.first().url).isEqualTo("ssh://[2001:db8::1]/path") + } + + @Test + fun `linkification ignores a bracketed authority which is not an address`() { + val text = "A url http://[not-an-address]/path" + val result = LinkifyHelper.linkify(text) + val urlSpans = result.toSpannable().getSpans() + assertThat(urlSpans).isEmpty() + } + @Test fun `linkification ignores bracketed text which is not a URL`() { val text = "An array values[0]:1 and a bare [::1]" From 690ffa54ce806a84b5d71c0f54e5d7bb8c56fa93 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:12:14 +0000 Subject: [PATCH 3/7] Update plugin sonarqube to v7.4.0.8496 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 61b053b3a5..1ab1574c9c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -276,7 +276,7 @@ paparazzi = "app.cash.paparazzi:2.0.0-alpha05" roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" } sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } firebaseAppDistribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppDistribution" } -sonarqube = "org.sonarqube:7.3.1.8318" +sonarqube = "org.sonarqube:7.4.0.8496" licensee = "app.cash.licensee:1.14.1" compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } gms_google_services = { id = "com.google.gms.google-services", version = "4.5.0" } From 590e33de7167ed2e70d61b9ac1f91e28259adc52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Thu, 13 Aug 2026 09:46:16 +0200 Subject: [PATCH 4/7] Add explicit dependency of `sonarResolver` task to `processResources` Otherwise `:codegen:sonarResolver` fails --- codegen/build.gradle.kts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/codegen/build.gradle.kts b/codegen/build.gradle.kts index 5c71c170bf..396ef665eb 100644 --- a/codegen/build.gradle.kts +++ b/codegen/build.gradle.kts @@ -1,3 +1,5 @@ +import org.sonarqube.gradle.SonarResolverTask + /* * Copyright (c) 2025 Element Creations Ltd. * Copyright 2022-2025 New Vector Ltd. @@ -18,3 +20,9 @@ dependencies { implementation(libs.ksp.plugin) implementation(libs.kotlinpoet.ksp) } + +// Ensure that the processResources task is executed before the SonarResolverTask to avoid issues with missing resources during SonarQube analysis. +// This *should not be necesssary* but is a workaround for a known issue with the SonarQube Gradle plugin. +tasks.withType().configureEach { + dependsOn("processResources") +} From d017a84b2b49122f497ca3c22593e7ac202b7c0f Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 13 Aug 2026 11:47:22 +0200 Subject: [PATCH 5/7] Remove useless let statement. --- .../textcomposer/components/markdown/MarkdownTextInput.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt index 52ec2b762a..63f9e0a8ea 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt @@ -133,11 +133,9 @@ fun MarkdownTextInput( update = { editText -> editText.contentDescription = placeholder editText.applyStyleInCompose(richTextEditorStyle) - editText.onEnterKeyListener = onSendMessage?.let { send -> - { - send() - true - } + editText.onEnterKeyListener = { + onSendMessage() + true } val text = state.text.value() mentionSpanUpdater.updateMentionSpans(text) From 07e90ff704bb3e8be2621c84426917c8767c40bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Thu, 13 Aug 2026 12:10:06 +0200 Subject: [PATCH 6/7] Fix `MarkdownTextInputTest` using the previous `onSendMessage` signature --- .../impl/components/markdown/MarkdownTextInputTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt index 15687cb4c9..d08048241e 100644 --- a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt @@ -191,7 +191,7 @@ class MarkdownTextInputTest : RobolectricTest() { state: MarkdownTextEditorState = aMarkdownTextEditorState(), onTyping: (Boolean) -> Unit = {}, onSuggestionReceived: (Suggestion?) -> Unit = {}, - onSendMessage: (() -> Unit)? = null, + onSendMessage: (() -> Unit) = {}, ) { setContent { val style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus) From 913b566270fd7f3ce8fce4533815ab20303b27cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Thu, 13 Aug 2026 10:35:36 +0200 Subject: [PATCH 7/7] Add missing tests for `RustTemporaryMatrixClient` --- .../impl/RustTemporaryMatrixClientTest.kt | 92 +++++++++++++++++++ .../impl/fixtures/fakes/FakeFfiClient.kt | 5 + 2 files changed, 97 insertions(+) create mode 100644 libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientTest.kt diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientTest.kt new file mode 100644 index 0000000000..322103042b --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientTest.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.exception.ClientException +import io.element.android.libraries.matrix.api.paths.SessionPaths +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.matrix.rustcomponents.sdk.Client +import org.matrix.rustcomponents.sdk.NoHandle +import java.io.File +import kotlin.io.path.createTempDirectory +import org.matrix.rustcomponents.sdk.ClientException as RustClientException + +class RustTemporaryMatrixClientTest { + @Test + fun `getUrl method uses client getUrl`() = runTest { + val paths = SessionPaths( + fileDirectory = File("files"), + cacheDirectory = File("cache"), + ) + + val getUrlRecorder = lambdaRecorder { ByteArray(0) } + val client = createRustTemporaryMatrixClient( + client = FakeFfiClient( + getUrlResult = getUrlRecorder, + ), + paths = paths, + ) + val url = "https://example.com" + + client.getUrl(url) + + getUrlRecorder.assertions().isCalledOnce().with(value(url)) + } + + @Test + fun `getUrl method can fail gracefully and map errors to public ones`() = runTest { + val paths = SessionPaths( + fileDirectory = File("files"), + cacheDirectory = File("cache"), + ) + + val getUrlRecorder = lambdaRecorder { throw RustClientException.Generic("Error", null) } + val client = createRustTemporaryMatrixClient( + client = FakeFfiClient( + getUrlResult = getUrlRecorder, + ), + paths = paths, + ) + val url = "https://example.com" + + val result = client.getUrl(url) + + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isInstanceOf(ClientException.Generic::class.java) + } + + @Test + fun `close deletes the directories if they exist`() = runTest { + val paths = SessionPaths( + fileDirectory = createTempDirectory().toFile(), + cacheDirectory = createTempDirectory().toFile(), + ) + + val client = createRustTemporaryMatrixClient(paths = paths) + + assertThat(paths.fileDirectory.exists()).isTrue() + assertThat(paths.cacheDirectory.exists()).isTrue() + + client.close() + + assertThat(paths.fileDirectory.exists()).isFalse() + assertThat(paths.cacheDirectory.exists()).isFalse() + } + + private fun createRustTemporaryMatrixClient( + client: Client = Client(NoHandle), + paths: SessionPaths, + ): RustTemporaryMatrixClient { + return RustTemporaryMatrixClient(client, paths) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt index a2c1f5b017..13be3cf2d5 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt @@ -60,6 +60,7 @@ class FakeFfiClient( private val setAccountDataResult: (String, String) -> Unit = { _, _ -> lambdaError() }, private val isUserStatusSupportedResult: () -> Boolean = { false }, private val subscribeToOwnProfileResult: (ProfileListener) -> Unit = {}, + private val getUrlResult: (String) -> ByteArray = { lambdaError() }, private val closeResult: () -> Unit = {}, ) : Client(NoHandle) { override fun userId(): String = userId @@ -134,5 +135,9 @@ class FakeFfiClient( setAccountDataResult(eventType, content) } + override suspend fun getUrl(url: String): ByteArray = simulateLongTask { + getUrlResult(url) + } + override fun close() = closeResult() }