Merge develop into the wildcard mime type branch

Develop gained resolveImageMimeType, which reads the image header so a
share that declares a broad type is encoded as the format it really is.
Resolving the wildcard first would hand that helper a concrete image/jpeg
and make it return early, so a transparent PNG shared as image/* would be
flattened and an animated GIF would be recompressed. The header is now
read from the type the sender declared and the default subtype is applied
to whatever comes back, so the sniffing still runs and an undecodable file
no longer keeps a wildcard.

A non-image shared as image/* is therefore sent as image/jpeg rather than
image/*, which is the whole point of this branch, so the test covering
that case now expects the default subtype.
This commit is contained in:
hayaksi1
2026-08-20 14:26:57 +03:00
418 changed files with 6374 additions and 4183 deletions
@@ -135,6 +135,7 @@ import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import java.io.File
import java.util.Optional
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.jvm.optionals.getOrNull
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@@ -162,6 +163,7 @@ class RustMatrixClient(
) : MatrixClient {
override val sessionId: UserId = UserId(innerClient.userId())
override val deviceId: DeviceId = DeviceId(innerClient.deviceId())
override val server: String? = innerClient.server()
override val homeserverUrl: String = innerClient.homeserver()
override val sessionCoroutineScope = appCoroutineScope.childScope(dispatchers.main, "Session-$sessionId")
private val sessionDispatcher = dispatchers.io.limitedParallelism(64)
@@ -304,6 +306,10 @@ class RustMatrixClient(
.buffer(Channel.UNLIMITED)
.stateIn(sessionCoroutineScope, started = SharingStarted.Eagerly, initialValue = persistentListOf())
private val _isShuttingDown = AtomicBoolean(false)
override val isShuttingDown: Boolean
get() = _isShuttingDown.get()
init {
// Make sure the session delegate has a reference to the client to be able to logout on auth error
sessionDelegate.bindClient(this)
@@ -320,7 +326,9 @@ class RustMatrixClient(
}
private suspend fun setupUserProfile() {
val supported = isUserStatusSupported().getOrDefault(false)
// Subscribing to own profile updates only requires the Profiles sliding sync extension,
// not the full user status capability (which also needs the status profile field to be settable).
val supported = isProfilesSlidingSyncExtensionSupported().getOrDefault(false)
if (supported) {
// No need to seed the data here, it's already stored by the sdk.
ownProfileTaskHandle = innerClient.subscribeToOwnProfile(ownProfileListener)
@@ -530,6 +538,12 @@ class RustMatrixClient(
}
}
override suspend fun isProfilesSlidingSyncExtensionSupported(): Result<Boolean> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.isProfilesSlidingSyncExtensionSupported()
}
}
override fun enableAutomaticCallStatus(enabled: Boolean) {
innerClient.enableAutomaticCallStatus(enabled)
}
@@ -666,11 +680,13 @@ class RustMatrixClient(
}
override suspend fun clearCache() {
_isShuttingDown.set(true)
innerClient.clearCaches(innerSyncService)
destroy()
}
override suspend fun logout(userInitiated: Boolean, ignoreSdkError: Boolean) {
_isShuttingDown.set(true)
sessionCoroutineScope.cancel()
// Remove current delegate so we don't receive an auth error
clientDelegateTaskHandle?.cancelAndDestroy()
@@ -708,6 +724,8 @@ class RustMatrixClient(
}
override suspend fun deactivateAccount(password: String, eraseData: Boolean): Result<Unit> = withContext(sessionDispatcher) {
_isShuttingDown.set(true)
Timber.w("Deactivating account")
// Remove current delegate so we don't receive an auth error
clientDelegateTaskHandle?.cancelAndDestroy()
@@ -22,6 +22,9 @@ class RustTemporaryMatrixClient(
client.getUrl(url)
}.mapFailure { it.mapClientException() }
override val server: String? = client.server()
override val homeserverUrl: String = client.homeserver()
override fun close() {
client.close()
paths?.let {
@@ -19,7 +19,7 @@ class RustTemporaryMatrixClientFactory(
private val sessionPathsFactory: SessionPathsFactory,
private val rustMatrixClientFactory: RustMatrixClientFactory,
) : TemporaryMatrixClientFactory {
override suspend fun create(homeServerUrl: String): Result<TemporaryMatrixClient> {
override suspend fun create(serverName: String): Result<TemporaryMatrixClient> {
return runCatchingExceptions {
val sessionPaths = sessionPathsFactory.create()
val client = rustMatrixClientFactory.getBaseClientBuilder(
@@ -28,7 +28,7 @@ class RustTemporaryMatrixClientFactory(
slidingSyncType = ClientBuilderSlidingSync.Native,
isMessageSearchAvailable = false,
)
.homeserverUrl(homeServerUrl)
.serverName(serverName)
.build()
RustTemporaryMatrixClient(client, sessionPaths)
}
@@ -29,6 +29,7 @@ fun Throwable.mapAuthenticationException(): AuthenticationException {
is ClientBuildException.WellKnownLookupFailed -> AuthenticationException.Generic(message)
is ClientBuildException.EventCache -> AuthenticationException.Generic(message)
is ClientBuildException.InvalidRawKey -> AuthenticationException.Generic(message)
is ClientBuildException.WellKnownLookupDisabled -> AuthenticationException.Generic(message)
}
is OAuthException -> when (this) {
is OAuthException.Generic -> AuthenticationException.OAuth(message)
@@ -278,7 +278,6 @@ class RustMatrixAuthenticationService(
.let {
enterpriseService.tweakMasUrl(
url = it,
homeserver = client.server() ?: client.homeserver(),
urlContentFetcher = getUrlResolver,
)
}
@@ -13,9 +13,9 @@ import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.ClientUrlContentFetcher
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
@@ -111,7 +111,7 @@ object SessionMatrixModule {
}
@Provides
fun providesGetUrlResolverClient(matrixClient: MatrixClient): UrlContentFetcher {
fun providesGetUrlResolverClient(matrixClient: MatrixClient): ClientUrlContentFetcher {
return matrixClient
}
}
@@ -8,11 +8,13 @@
package io.element.android.libraries.matrix.impl.permalink
import android.net.Uri
import androidx.core.net.toUri
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
@@ -51,6 +53,10 @@ class DefaultPermalinkParser(
matrixToConverter.convert(uri) ?: return PermalinkData.FallbackLink(uri)
}
matrixToUri.userIdWithSlash()?.let { userId ->
return PermalinkData.UserLink(userId = userId)
}
val result = runCatchingExceptions {
parseMatrixEntityFrom(matrixToUri.toString())
}.getOrNull()
@@ -84,3 +90,17 @@ class DefaultPermalinkParser(
}
}
}
/**
* A slash is allowed in the local part of a user id, but it also separates an identifier from an
* event id in a permalink, so [parseMatrixEntityFrom] cannot tell the two apart. Only a user id can
* start with an `@`, so recover it here rather than letting the ambiguity reach the SDK.
*/
private fun Uri.userIdWithSlash(): UserId? {
val identifier = fragment
?.removePrefix("/")
?.substringBefore('?')
?: return null
if (!identifier.startsWith("@") || !identifier.contains('/')) return null
return identifier.takeIf { MatrixPatterns.isUserId(it) }?.let(::UserId)
}
@@ -75,6 +75,7 @@ import org.matrix.rustcomponents.sdk.PollData
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
import org.matrix.rustcomponents.sdk.UploadParameters
import org.matrix.rustcomponents.sdk.UploadSource
import org.matrix.rustcomponents.sdk.createCaptionEdit
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import uniffi.matrix_sdk.PaginationStatus
@@ -341,7 +342,7 @@ class RustTimeline(
formattedCaption: String?,
): Result<Unit> = withContext(dispatcher) {
runCatchingExceptions<Unit> {
val editedContent = EditedContent.MediaCaption(
val editedContent = createCaptionEdit(
caption = caption,
formattedCaption = formattedCaption?.let {
FormattedBody(body = it, format = MessageFormat.Html)
@@ -365,7 +366,7 @@ class RustTimeline(
fromNotification: Boolean,
msgType: MsgType,
): Result<Unit> = withContext(dispatcher) {
runCatchingExceptions {
runCatchingExceptions<Unit> {
val msg = MessageEventContent.from(
body = body,
htmlBody = htmlBody,
@@ -16,7 +16,6 @@ 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
@@ -84,7 +83,7 @@ class RustTemporaryMatrixClientTest {
}
private fun createRustTemporaryMatrixClient(
client: Client = Client(NoHandle),
client: Client = FakeFfiClient(),
paths: SessionPaths,
): RustTemporaryMatrixClient {
return RustTemporaryMatrixClient(client, paths)
@@ -12,6 +12,7 @@ import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSession
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUserProfile
import io.element.android.libraries.matrix.test.A_DEVICE_ID
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.A_SERVER_NAME
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
@@ -45,6 +46,7 @@ class FakeFfiClient(
private val userId: String = A_USER_ID.value,
private val deviceId: String = A_DEVICE_ID.value,
private val homeserver: String = A_HOMESERVER_URL,
private val server: String? = A_SERVER_NAME,
private val notificationClient: NotificationClient = FakeFfiNotificationClient(),
private val notificationSettings: NotificationSettings = FakeFfiNotificationSettings(),
private val encryption: Encryption = FakeFfiEncryption(),
@@ -59,6 +61,7 @@ class FakeFfiClient(
private val accountDataResult: (String) -> String? = { lambdaError() },
private val setAccountDataResult: (String, String) -> Unit = { _, _ -> lambdaError() },
private val isUserStatusSupportedResult: () -> Boolean = { false },
private val isProfilesSlidingSyncExtensionSupportedResult: () -> Boolean = { false },
private val subscribeToOwnProfileResult: (ProfileListener) -> Unit = {},
private val getUrlResult: (String) -> ByteArray = { lambdaError() },
private val closeResult: () -> Unit = {},
@@ -66,6 +69,7 @@ class FakeFfiClient(
override fun userId(): String = userId
override fun deviceId(): String = deviceId
override fun homeserver(): String = homeserver
override fun server(): String? = server
override suspend fun notificationClient(processSetup: NotificationProcessSetup) = notificationClient
override suspend fun getNotificationSettings(): NotificationSettings = notificationSettings
override fun encryption(): Encryption = encryption
@@ -100,6 +104,8 @@ class FakeFfiClient(
override suspend fun isUserStatusSupported(): Boolean = isUserStatusSupportedResult()
override suspend fun isProfilesSlidingSyncExtensionSupported(): Boolean = isProfilesSlidingSyncExtensionSupportedResult()
override fun subscribeToOwnProfile(listener: ProfileListener): TaskHandle {
subscribeToOwnProfileResult(listener)
return FakeFfiTaskHandle()
@@ -0,0 +1,44 @@
/*
* 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.permalink
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
class DefaultPermalinkParserTest : RobolectricTest() {
@Test
fun `a matrix-to link to a user id with a slash is parsed as a user link`() {
val result = createParser().parse("https://matrix.to/#/@it/sme:matrix.org")
assertThat(result).isEqualTo(PermalinkData.UserLink(UserId("@it/sme:matrix.org")))
}
@Test
fun `an escaped slash in a user id is parsed as a user link`() {
val result = createParser().parse("https://matrix.to/#/@it%2Fsme:matrix.org")
assertThat(result).isEqualTo(PermalinkData.UserLink(UserId("@it/sme:matrix.org")))
}
@Test
fun `an element web link to a user id with a slash is parsed as a user link`() {
val result = createParser().parse("https://app.element.io/#/user/@it/sme:matrix.org")
assertThat(result).isEqualTo(PermalinkData.UserLink(UserId("@it/sme:matrix.org")))
}
@Test
fun `a user id with a slash that is not a valid user id is not a user link`() {
val result = createParser().parse("https://example.org/foo/@it/sme")
assertThat(result).isInstanceOf(PermalinkData.FallbackLink::class.java)
}
private fun createParser() = DefaultPermalinkParser(
matrixToConverter = DefaultMatrixToConverter(),
)
}