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") +} 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" } 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 7cfcb5d6e6..090936910e 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,6 +24,8 @@ import timber.log.Timber * It also checks the linkified results to make sure URLs spans are not including trailing punctuation. */ object LinkifyHelper { + private val BRACKETED_AUTHORITY_URL_REGEX = Regex("""(?(start, newEnd)) { + spannable.removeSpan(span) + } + spannable.setSpan(URLSpan(url), 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 @@ -87,6 +108,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 Spannable.isEmailInsideFediverseHandle(urlSpan: URLSpan, start: Int): Boolean { return urlSpan.url.startsWith("mailto:") && start > 0 && this[start - 1] == '@' } 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 df21d266e4..130d640d4a 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,60 @@ 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 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]" + 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" 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() } 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) 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)