Merge remote-tracking branch 'origin/develop' into feature/account-provider-selection

This commit is contained in:
Jenna Vassar
2026-08-18 17:49:31 -07:00
33 changed files with 432 additions and 508 deletions
@@ -136,6 +136,9 @@ interface MatrixClient : UrlContentFetcher {
/** Scans media content before it is displayed, or `null` when no content scanner is configured for this session. */
val contentScanner: ContentScanner?
/** Whether the session is in the process of shutting down, either because it is being logged out or its cache is being cleared. */
val isShuttingDown: Boolean
/**
* Returns the room with the given id if the user has joined it, along with its live [Timeline].
* A new instance is created on each call and the caller owns it, so it must be closed once no longer needed.
@@ -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
@@ -304,6 +305,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)
@@ -666,11 +671,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 +715,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()
@@ -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)
@@ -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,
@@ -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(),
)
}
@@ -133,6 +133,7 @@ class FakeMatrixClient(
private val resetWellKnownConfigLambda: () -> Result<Unit> = { lambdaError() },
private val enableAutomaticCallStatusLambda: (Boolean) -> Unit = { },
override val contentScanner: ContentScanner? = null,
private val isShuttingDownResult: () -> Boolean = { false },
) : MatrixClient {
var setDisplayNameCalled: Boolean = false
private set
@@ -305,6 +306,9 @@ class FakeMatrixClient(
return knockRoomLambda(roomIdOrAlias, message, serverNames)
}
override val isShuttingDown: Boolean
get() = isShuttingDownResult()
// Mocks
fun givenCreateRoomResult(result: Result<RoomId>) {
@@ -62,10 +62,10 @@ class AndroidMediaPreProcessor(
/**
* Used for calculating `inSampleSize` for bitmaps.
*
* *Note*: Ideally, this should result in images of up to (but not included) 1280x1280 being sent. However, images with very different width and height
* *Note*: Ideally, this should result in images of up to (but not included) 2560x2560 being sent. However, images with very different width and height
* values may surpass this limit. (i.e.: an image of `480x3000px` would have `inSampleSize=1` and be sent as is).
*/
private const val IMAGE_SCALE_REF_SIZE = 640
private const val IMAGE_SCALE_REF_SIZE = 1280
private val notCompressibleImageTypes = listOf(MimeTypes.Gif, MimeTypes.WebP)
}
@@ -85,8 +85,9 @@ class AndroidMediaPreProcessor(
val result = when {
mimeType == MimeTypes.Svg -> processSvgImage(uri, mimeType)
mimeType.isMimeTypeImage() -> {
val shouldBeCompressed = mediaOptimizationConfig.compressImages && mimeType !in notCompressibleImageTypes
processImage(uri, mimeType, shouldBeCompressed)
val imageMimeType = resolveImageMimeType(uri, mimeType)
val shouldBeCompressed = mediaOptimizationConfig.compressImages && imageMimeType !in notCompressibleImageTypes
processImage(uri, imageMimeType, shouldBeCompressed)
}
mimeType.isMimeTypeVideo() -> processVideo(uri, mimeType, mediaOptimizationConfig.videoCompressionPreset)
mimeType.isMimeTypeAudio() -> processAudio(uri, mimeType)
@@ -155,6 +156,19 @@ class AndroidMediaPreProcessor(
}
}
/**
* The declared mime type can be broader than the image actually is - a wildcard from a share intent, or a mislabelled file - and the encoder is picked
* from it. Re-encoding a transparent image as JPEG flattens its alpha channel to black, so resolve the real format from the image header first.
*/
private fun resolveImageMimeType(uri: Uri, declaredMimeType: String): String {
if (declaredMimeType == MimeTypes.Png || declaredMimeType == MimeTypes.Jpeg) return declaredMimeType
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
tryOrNull {
contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, options) }
}
return options.outMimeType ?: declaredMimeType
}
private suspend fun processImage(uri: Uri, mimeType: String, shouldBeCompressed: Boolean): MediaUploadInfo {
Timber.d("Processing image ${uri.path.orEmpty().hash()}")
@@ -70,6 +70,7 @@ class AndroidMediaPreProcessorTest : RobolectricTest() {
mediaOptimizationConfig: MediaOptimizationConfig,
sdkIntVersion: Int = Build.VERSION_CODES.P,
deleteOriginal: Boolean = false,
mimeType: String = asset.mimeType,
): MediaUploadInfo {
val context = InstrumentationRegistry.getInstrumentation().context
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
@@ -81,7 +82,7 @@ class AndroidMediaPreProcessorTest : RobolectricTest() {
val file = getFileFromAssets(context, asset.filename)
val result = sut.process(
uri = file.toUri(),
mimeType = asset.mimeType,
mimeType = mimeType,
deleteOriginal = deleteOriginal,
mediaOptimizationConfig = mediaOptimizationConfig,
)
@@ -91,6 +92,52 @@ class AndroidMediaPreProcessorTest : RobolectricTest() {
return data
}
@Test
fun `a png shared with a wildcard mime type is still encoded as a png`() = runTest {
val mediaUploadInfo = process(
asset = assetImagePng,
mimeType = MimeTypes.Images,
mediaOptimizationConfig = MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
),
sdkIntVersion = Build.VERSION_CODES.Q,
)
val info = mediaUploadInfo as MediaUploadInfo.Image
assertThat(info.imageInfo.mimetype).isEqualTo(MimeTypes.Png)
}
@Test
fun `a gif shared with a wildcard mime type is recognised and left uncompressed`() = runTest {
val mediaUploadInfo = process(
asset = assetAnimatedGif,
mimeType = MimeTypes.Images,
mediaOptimizationConfig = MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
),
sdkIntVersion = Build.VERSION_CODES.Q,
)
val info = mediaUploadInfo as MediaUploadInfo.Image
assertThat(info.imageInfo.mimetype).isEqualTo(MimeTypes.Gif)
assertThat(info.imageInfo.size).isEqualTo(assetAnimatedGif.size)
}
@Test
fun `a file that is not an image keeps the wildcard mime type it was shared with`() = runTest {
val mediaUploadInfo = process(
asset = assetText,
mimeType = MimeTypes.Images,
mediaOptimizationConfig = MediaOptimizationConfig(
compressImages = false,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
),
sdkIntVersion = Build.VERSION_CODES.Q,
)
val info = mediaUploadInfo as MediaUploadInfo.Image
assertThat(info.imageInfo.mimetype).isEqualTo(MimeTypes.Images)
}
@Test
@Ignore("Ignore now that min API for enterprise is 33")
fun `test processing png`() = runTest {
@@ -208,10 +255,10 @@ class AndroidMediaPreProcessorTest : RobolectricTest() {
assertThat(info.thumbnailFile).isNotNull()
assertThat(info.imageInfo).isEqualTo(
ImageInfo(
height = 979,
width = 3006,
height = 1958,
width = 6012,
mimetype = MimeTypes.Jpeg,
size = 84_845,
size = 682_492,
ThumbnailInfo(height = 244, width = 751, mimetype = MimeTypes.Jpeg, size = 7_178),
thumbnailSource = null,
blurhash = "K07gBzX=j_D4xZjoaSe,s:"
@@ -233,10 +280,10 @@ class AndroidMediaPreProcessorTest : RobolectricTest() {
assertThat(info.thumbnailFile).isNull()
assertThat(info.imageInfo).isEqualTo(
ImageInfo(
height = 979,
width = 3_006,
height = 1_958,
width = 6_012,
mimetype = MimeTypes.Jpeg,
size = 84_845,
size = 682_492,
thumbnailInfo = null,
thumbnailSource = null,
blurhash = null,
+2 -2
View File
@@ -2,6 +2,6 @@
## Configuration
In order to make this module only know about Firebase, the plugin `com.google.gms.google-services` has been disabled from the `app` module.
In order to make only this module know about Firebase, the plugin `com.google.gms.google-services` has been disabled from the `app` module.
To be able to change the values set to `google_app_id` in the file `build.gradle.kts` of this module, you should enable the plugin `com.google.gms.google-services` again, copy the file `google-services.json` to the folder `/app/src/main`, build the project, and check the generated file `app/build/generated/res/google-services/<buildtype>/values/values.xml` to import the generated values into the `build.gradle.kts` files.
To be able to change the values set to `google_app_id` in the file `build.gradle.kts` of this module, you should enable the plugin `com.google.gms.google-services` again, copy the file `google-services.json` to the folder `/app`, build the project, and check the generated file `app/build/generated/res/google-services/<buildtype>/values/values.xml` to import the generated values into the `firebase.xml` files.
@@ -8,7 +8,6 @@
@file:Suppress("UnstableApiUsage")
import config.BuildTimeConfig
import extension.setupDependencyInjection
import extension.testCommonDependencies
import org.gradle.kotlin.dsl.withType
@@ -28,27 +27,10 @@ android {
buildTypes {
getByName("release") {
consumerProguardFiles("consumer-proguard-rules.pro")
resValue(
type = "string",
name = "google_app_id",
value = BuildTimeConfig.GOOGLE_APP_ID_RELEASE,
)
}
getByName("debug") {
resValue(
type = "string",
name = "google_app_id",
value = BuildTimeConfig.GOOGLE_APP_ID_DEBUG,
)
}
register("nightly") {
consumerProguardFiles("consumer-proguard-rules.pro")
matchingFallbacks += listOf("release")
resValue(
type = "string",
name = "google_app_id",
value = BuildTimeConfig.GOOGLE_APP_ID_NIGHTLY,
)
}
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="google_app_id" translatable="false" tools:ignore="UnusedResources">1:912726360885:android:def0a4e454042e9b00427c</string>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="google_app_id" translatable="false" tools:ignore="UnusedResources">1:912726360885:android:e17435e0beb0303000427c</string>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="google_app_id" translatable="false" tools:ignore="UnusedResources">1:912726360885:android:d097de99a4c23d2700427c</string>
</resources>