mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-03 16:10:00 +00:00
Merge branch 'develop' into misc/reactions-pinned-messages
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
import org.sonarqube.gradle.SonarResolverTask
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2025 Element Creations Ltd.
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
* Copyright 2022-2025 New Vector Ltd.
|
* Copyright 2022-2025 New Vector Ltd.
|
||||||
@@ -18,3 +20,9 @@ dependencies {
|
|||||||
implementation(libs.ksp.plugin)
|
implementation(libs.ksp.plugin)
|
||||||
implementation(libs.kotlinpoet.ksp)
|
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<SonarResolverTask>().configureEach {
|
||||||
|
dependsOn("processResources")
|
||||||
|
}
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ paparazzi = "app.cash.paparazzi:2.0.0-alpha05"
|
|||||||
roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }
|
roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" }
|
||||||
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
|
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
|
||||||
firebaseAppDistribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppDistribution" }
|
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"
|
licensee = "app.cash.licensee:1.14.1"
|
||||||
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
gms_google_services = { id = "com.google.gms.google-services", version = "4.5.0" }
|
gms_google_services = { id = "com.google.gms.google-services", version = "4.5.0" }
|
||||||
|
|||||||
+26
@@ -16,6 +16,7 @@ import androidx.core.text.toSpannable
|
|||||||
import androidx.core.text.util.LinkifyCompat
|
import androidx.core.text.util.LinkifyCompat
|
||||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import java.net.URI
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper class to linkify text while preserving existing URL spans.
|
* 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.
|
* It also checks the linkified results to make sure URLs spans are not including trailing punctuation.
|
||||||
*/
|
*/
|
||||||
object LinkifyHelper {
|
object LinkifyHelper {
|
||||||
|
private val BRACKETED_AUTHORITY_URL_REGEX = Regex("""(?<![a-zA-Z0-9+.-])[a-zA-Z][a-zA-Z0-9+.-]*://\[\S*""")
|
||||||
|
|
||||||
fun linkify(
|
fun linkify(
|
||||||
text: CharSequence,
|
text: CharSequence,
|
||||||
@LinkifyCompat.LinkifyMask linkifyMask: Int = Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES,
|
@LinkifyCompat.LinkifyMask linkifyMask: Int = Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES,
|
||||||
@@ -72,6 +75,24 @@ object LinkifyHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 link span")
|
||||||
|
}.getOrNull() ?: end
|
||||||
|
|
||||||
|
val url = spannable.substring(start, newEnd)
|
||||||
|
if (!url.hasBracketedHost()) continue
|
||||||
|
|
||||||
|
for (span in spannable.getSpans<URLSpan>(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
|
// Restore old spans, remove new ones if there is a conflict
|
||||||
for ((urlSpan, location) in oldURLSpans) {
|
for ((urlSpan, location) in oldURLSpans) {
|
||||||
val (start, end) = location
|
val (start, end) = location
|
||||||
@@ -87,6 +108,11 @@ object LinkifyHelper {
|
|||||||
return spannable
|
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 {
|
private fun Spannable.isEmailInsideFediverseHandle(urlSpan: URLSpan, start: Int): Boolean {
|
||||||
return urlSpan.url.startsWith("mailto:") && start > 0 && this[start - 1] == '@'
|
return urlSpan.url.startsWith("mailto:") && start > 0 && this[start - 1] == '@'
|
||||||
}
|
}
|
||||||
|
|||||||
+54
@@ -36,6 +36,60 @@ class LinkifierHelperTest : RobolectricTest() {
|
|||||||
assertThat(urlSpans.first().url).isEqualTo("https://matrix.org")
|
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<URLSpan>()
|
||||||
|
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<URLSpan>()
|
||||||
|
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<URLSpan>()
|
||||||
|
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<URLSpan>()
|
||||||
|
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<URLSpan>()
|
||||||
|
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<URLSpan>()
|
||||||
|
assertThat(urlSpans).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `linkification finds partial URL`() {
|
fun `linkification finds partial URL`() {
|
||||||
val text = "A partial url matrix.org/test"
|
val text = "A partial url matrix.org/test"
|
||||||
|
|||||||
+92
@@ -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<String, ByteArray> { 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<String, ByteArray> { 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -60,6 +60,7 @@ class FakeFfiClient(
|
|||||||
private val setAccountDataResult: (String, String) -> Unit = { _, _ -> lambdaError() },
|
private val setAccountDataResult: (String, String) -> Unit = { _, _ -> lambdaError() },
|
||||||
private val isUserStatusSupportedResult: () -> Boolean = { false },
|
private val isUserStatusSupportedResult: () -> Boolean = { false },
|
||||||
private val subscribeToOwnProfileResult: (ProfileListener) -> Unit = {},
|
private val subscribeToOwnProfileResult: (ProfileListener) -> Unit = {},
|
||||||
|
private val getUrlResult: (String) -> ByteArray = { lambdaError() },
|
||||||
private val closeResult: () -> Unit = {},
|
private val closeResult: () -> Unit = {},
|
||||||
) : Client(NoHandle) {
|
) : Client(NoHandle) {
|
||||||
override fun userId(): String = userId
|
override fun userId(): String = userId
|
||||||
@@ -134,5 +135,9 @@ class FakeFfiClient(
|
|||||||
setAccountDataResult(eventType, content)
|
setAccountDataResult(eventType, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getUrl(url: String): ByteArray = simulateLongTask {
|
||||||
|
getUrlResult(url)
|
||||||
|
}
|
||||||
|
|
||||||
override fun close() = closeResult()
|
override fun close() = closeResult()
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-5
@@ -133,11 +133,9 @@ fun MarkdownTextInput(
|
|||||||
update = { editText ->
|
update = { editText ->
|
||||||
editText.contentDescription = placeholder
|
editText.contentDescription = placeholder
|
||||||
editText.applyStyleInCompose(richTextEditorStyle)
|
editText.applyStyleInCompose(richTextEditorStyle)
|
||||||
editText.onEnterKeyListener = onSendMessage?.let { send ->
|
editText.onEnterKeyListener = {
|
||||||
{
|
onSendMessage()
|
||||||
send()
|
true
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val text = state.text.value()
|
val text = state.text.value()
|
||||||
mentionSpanUpdater.updateMentionSpans(text)
|
mentionSpanUpdater.updateMentionSpans(text)
|
||||||
|
|||||||
+1
-1
@@ -191,7 +191,7 @@ class MarkdownTextInputTest : RobolectricTest() {
|
|||||||
state: MarkdownTextEditorState = aMarkdownTextEditorState(),
|
state: MarkdownTextEditorState = aMarkdownTextEditorState(),
|
||||||
onTyping: (Boolean) -> Unit = {},
|
onTyping: (Boolean) -> Unit = {},
|
||||||
onSuggestionReceived: (Suggestion?) -> Unit = {},
|
onSuggestionReceived: (Suggestion?) -> Unit = {},
|
||||||
onSendMessage: (() -> Unit)? = null,
|
onSendMessage: (() -> Unit) = {},
|
||||||
) {
|
) {
|
||||||
setContent {
|
setContent {
|
||||||
val style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus)
|
val style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus)
|
||||||
|
|||||||
Reference in New Issue
Block a user