Merge branch 'develop' into feature/fga/live_location_rendering

This commit is contained in:
ganfra 2026-04-17 15:13:13 +02:00
commit 22f0b2e449
334 changed files with 8886 additions and 2214 deletions

View file

@ -0,0 +1,33 @@
/*
* 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.androidutils.service
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.annotations.ApplicationContext
interface ServiceBinder {
fun bindService(service: Intent, conn: ServiceConnection, flags: Int): Boolean
fun unbindService(conn: ServiceConnection)
}
@ContributesBinding(AppScope::class)
class DefaultServiceBinder(
@ApplicationContext private val context: Context,
) : ServiceBinder {
override fun bindService(service: Intent, conn: ServiceConnection, flags: Int): Boolean {
return context.bindService(service, conn, flags)
}
override fun unbindService(conn: ServiceConnection) {
context.unbindService(conn)
}
}

View file

@ -0,0 +1,54 @@
/*
* 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.architecture.appyx
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.spring
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.navigation.transition.ModifierTransitionHandler
import com.bumble.appyx.core.navigation.transition.TransitionDescriptor
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.NewRoot
import com.bumble.appyx.navmodel.backstack.operation.Replace
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackFader
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackSlider
/**
* A TransitionHandler that uses fade transition when the operation is Replace or NewRoot,
* and slide transition for all other cases.
*/
private class FaderOrSliderTransitionHandler<NavTarget>(
private val slider: ModifierTransitionHandler<NavTarget, BackStack.State>,
private val fader: ModifierTransitionHandler<NavTarget, BackStack.State>,
) : ModifierTransitionHandler<NavTarget, BackStack.State>() {
override fun createModifier(
modifier: Modifier,
transition: Transition<BackStack.State>,
descriptor: TransitionDescriptor<NavTarget, BackStack.State>
): Modifier {
val operation = descriptor.operation
val useFader = operation is Replace || operation is NewRoot
val handler = if (useFader) fader else slider
return handler.createModifier(modifier, transition, descriptor)
}
}
@Composable
fun <NavTarget> rememberFaderOrSliderTransitionHandler(): ModifierTransitionHandler<NavTarget, BackStack.State> {
val slider = rememberBackstackSlider<NavTarget>(
transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) },
)
val fader = rememberBackstackFader<NavTarget>(
transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) },
)
return rememberDelegateTransitionHandler {
FaderOrSliderTransitionHandler(slider, fader)
}
}

View file

@ -13,16 +13,18 @@ import androidx.compose.ui.unit.dp
enum class AvatarSize(val dp: Dp) {
CurrentUserTopBar(32.dp),
CurrentRoomTopBar(32.dp),
IncomingCall(140.dp),
RoomDetailsHeader(96.dp),
RoomListItem(52.dp),
ThreadsListItem(52.dp),
SpaceListItem(52.dp),
RoomSelectRoomListItem(36.dp),
UserPreference(56.dp),
UserPreference(52.dp),
UserHeader(96.dp),
UserListItem(36.dp),

View file

@ -0,0 +1,79 @@
/*
* 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.designsystem.components.avatar
import android.graphics.Bitmap
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import io.element.android.libraries.designsystem.components.avatar.internal.InitialLetterAvatar
import timber.log.Timber
// For user avatar only.
@Composable
fun BitmapAvatar(
avatarData: AvatarData,
bitmap: Bitmap?,
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
val avatarShape = AvatarType.User.avatarShape()
when {
bitmap == null -> InitialLetterAvatar(
avatarData = avatarData,
avatarShape = avatarShape,
forcedAvatarSize = null,
modifier = modifier,
contentDescription = contentDescription,
)
else -> {
val size = avatarData.size.dp
SubcomposeAsyncImage(
model = bitmap,
contentDescription = contentDescription,
contentScale = ContentScale.Crop,
modifier = modifier
.size(size)
.clip(avatarShape)
) {
val collectedState by painter.state.collectAsState()
when (val state = collectedState) {
is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent()
is AsyncImagePainter.State.Error -> {
SideEffect {
Timber.e(
state.result.throwable,
"Error loading avatar $state\n${state.result}"
)
}
InitialLetterAvatar(
avatarData = avatarData,
avatarShape = avatarShape,
forcedAvatarSize = null,
contentDescription = contentDescription,
)
}
else -> InitialLetterAvatar(
avatarData = avatarData,
avatarShape = avatarShape,
forcedAvatarSize = null,
contentDescription = contentDescription,
)
}
}
}
}
}

View file

@ -8,16 +8,21 @@
package io.element.android.libraries.designsystem.preview
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Composable
import io.element.android.libraries.designsystem.utils.CommonDrawables
@Composable
fun ElementPreviewDark(
showBackground: Boolean = true,
content: @Composable () -> Unit
@DrawableRes
drawableFallbackForImages: Int = CommonDrawables.sample_background,
content: @Composable () -> Unit,
) {
ElementPreview(
darkTheme = true,
showBackground = showBackground,
content = content
drawableFallbackForImages = drawableFallbackForImages,
content = content,
)
}

View file

@ -8,16 +8,21 @@
package io.element.android.libraries.designsystem.preview
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Composable
import io.element.android.libraries.designsystem.utils.CommonDrawables
@Composable
fun ElementPreviewLight(
showBackground: Boolean = true,
content: @Composable () -> Unit
@DrawableRes
drawableFallbackForImages: Int = CommonDrawables.sample_background,
content: @Composable () -> Unit,
) {
ElementPreview(
darkTheme = false,
showBackground = showBackground,
content = content
drawableFallbackForImages = drawableFallbackForImages,
content = content,
)
}

View file

@ -0,0 +1,19 @@
/*
* 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.designsystem.utils
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass
import androidx.compose.runtime.Composable
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun hasCompactHeightWindowSize(): Boolean {
return currentWindowAdaptiveInfo().windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact
}

View file

@ -155,4 +155,11 @@ enum class FeatureFlags(
defaultValue = { false },
isFinished = false,
),
RoomThreadList(
key = "feature.room_thread_list",
title = "Add a list of threads in a room",
description = "Add a new screen with a list of threads in a room.",
defaultValue = { false },
isFinished = false,
),
}

View file

@ -0,0 +1,30 @@
/*
* 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.api
/**
* Provides information about the capabilities of the homeserver.
*
* Spec: https://spec.matrix.org/latest/client-server-api/#capabilities-negotiation
*/
interface HomeserverCapabilitiesProvider {
/**
* Manually refresh the capabilities of the homeserver performing a network request.
*/
suspend fun refresh(): Result<Unit>
/**
* Indicates whether the homeserver allows the user to change their display name.
*/
suspend fun canChangeDisplayName(): Result<Boolean>
/**
* Indicates whether the homeserver allows the user to change their avatar URL.
*/
suspend fun canChangeAvatarUrl(): Result<Boolean>
}

View file

@ -223,6 +223,8 @@ interface MatrixClient {
* Resets the cached client `well-known` config by the SDK.
*/
suspend fun resetWellKnownConfig(): Result<Unit>
fun homeserverCapabilities(): HomeserverCapabilitiesProvider
}
/**

View file

@ -0,0 +1,18 @@
/*
* 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.api.auth
import io.element.android.libraries.matrix.api.core.UserId
data class ElementClassicSession(
val userId: UserId,
val homeserverUrl: String?,
val secrets: String?,
val roomKeysVersion: String?,
val doesContainBackupKey: Boolean,
)

View file

@ -14,6 +14,7 @@ import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
interface MatrixAuthenticationService {
/**
@ -52,6 +53,20 @@ interface MatrixAuthenticationService {
*/
suspend fun cancelOidcLogin(): Result<Unit>
/**
* Set the existing data about Element Classic session, if any.
*/
fun setElementClassicSession(session: ElementClassicSession?)
/**
* Check if the provided secrets from Element Classic session contain a key backup.
*/
fun doSecretsContainBackupKey(
userId: UserId,
secrets: String,
backupInfo: String,
): Boolean
/**
* Attempt to login using the [callbackUrl] provided by the Oidc page.
*/

View file

@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.room.knock.KnockRequest
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.room.threads.ThreadsListService
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
@ -44,6 +45,8 @@ interface JoinedRoom : BaseRoom {
*/
val liveTimeline: Timeline
val threadsListService: ThreadsListService
/**
* Create a new timeline.
* @param createTimelineParams contains parameters about how to filter the timeline. Will also configure the date separators.

View file

@ -0,0 +1,34 @@
/*
* 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.api.room.threads
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toThreadId
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
@Immutable
data class ThreadListItem(
val rootEvent: ThreadListItemEvent,
val latestEvent: ThreadListItemEvent?,
val numberOfReplies: Long,
) {
val threadId = rootEvent.eventId.toThreadId()
}
@Immutable
data class ThreadListItemEvent(
val eventId: EventId,
val senderId: UserId,
val senderProfile: ProfileDetails,
val isOwn: Boolean,
val content: EventContent?,
val timestamp: Long,
)

View file

@ -0,0 +1,16 @@
/*
* 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.api.room.threads
sealed interface ThreadListPaginationStatus {
data class Idle(
val hasMoreToLoad: Boolean,
) : ThreadListPaginationStatus
data object Loading : ThreadListPaginationStatus
}

View file

@ -0,0 +1,18 @@
/*
* 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.api.room.threads
import kotlinx.coroutines.flow.Flow
interface ThreadsListService {
fun subscribeToItemUpdates(): Flow<List<ThreadListItem>>
fun subscribeToPaginationUpdates(): Flow<ThreadListPaginationStatus>
suspend fun paginate(): Result<Unit>
suspend fun reset(): Result<Unit>
fun destroy()
}

View file

@ -0,0 +1,20 @@
/*
* 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.api.core
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class UserIdTest {
@Test
fun `valid user id`() {
val userId = UserId("@alice:example.org")
assertThat(userId.extractedDisplayName).isEqualTo("alice")
assertThat(userId.domainName).isEqualTo("example.org")
}
}

View file

@ -28,7 +28,7 @@ dependencies {
} else {
debugImplementation(libs.matrix.sdk)
}
implementation(files("libs/rustls-platform-verifier-android.aar"))
implementation(projects.libraries.rustlsTls)
implementation(projects.appconfig)
implementation(projects.libraries.androidutils)

View file

@ -1 +0,0 @@
Updated rustls-platform-verifier-android.aar using `rustls-platform-verifier-0.1.1.aar`

View file

@ -0,0 +1,28 @@
/*
* 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 io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
import org.matrix.rustcomponents.sdk.HomeserverCapabilities
class RustHomeserverCapabilitiesProvider(
private val homeserverCapabilities: HomeserverCapabilities,
) : HomeserverCapabilitiesProvider {
override suspend fun refresh(): Result<Unit> = runCatchingExceptions {
homeserverCapabilities.refresh()
}
override suspend fun canChangeDisplayName(): Result<Boolean> = runCatchingExceptions {
homeserverCapabilities.canChangeDisplayname()
}
override suspend fun canChangeAvatarUrl(): Result<Boolean> = runCatchingExceptions {
homeserverCapabilities.canChangeAvatar()
}
}

View file

@ -17,6 +17,7 @@ import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes
import io.element.android.libraries.matrix.api.core.DeviceId
@ -835,6 +836,10 @@ class RustMatrixClient(
val request = PerformDatabaseVacuumRequestBuilder(sessionId)
sessionCoroutineScope.launch { workManagerScheduler.submit(request) }
}
override fun homeserverCapabilities(): HomeserverCapabilitiesProvider {
return RustHomeserverCapabilitiesProvider(innerClient.homeserverCapabilities())
}
}
private fun defaultRoomCreationPowerLevels(isPublic: Boolean, isSpace: Boolean) = PowerLevels(

View file

@ -16,6 +16,7 @@ import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.auth.AuthenticationException
import io.element.android.libraries.matrix.api.auth.ElementClassicSession
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.auth.OidcDetails
@ -25,6 +26,7 @@ import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.impl.ClientBuilderSlidingSync
import io.element.android.libraries.matrix.impl.RustMatrixClientFactory
@ -50,6 +52,7 @@ import org.matrix.rustcomponents.sdk.QrCodeData
import org.matrix.rustcomponents.sdk.QrCodeDecodeException
import org.matrix.rustcomponents.sdk.QrLoginProgress
import org.matrix.rustcomponents.sdk.QrLoginProgressListener
import org.matrix.rustcomponents.sdk.SecretsBundleWithUserId
import timber.log.Timber
import uniffi.matrix_sdk.OAuthAuthorizationData
import kotlin.time.Duration.Companion.seconds
@ -64,6 +67,9 @@ class RustMatrixAuthenticationService(
private val passphraseGenerator: PassphraseGenerator,
private val oidcConfigurationProvider: OidcConfigurationProvider,
) : MatrixAuthenticationService {
// Any existing Element Classic session that we want to try to import secrets from during login.
private var elementClassicSession: ElementClassicSession? = null
// Passphrase which will be used for new sessions. Existing sessions will use the passphrase
// stored in the SessionData.
private val pendingPassphrase = getDatabasePassphrase()
@ -138,9 +144,15 @@ class RustMatrixAuthenticationService(
runCatchingExceptions {
val client = currentClient ?: error("You need to call `setHomeserver()` first")
val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first")
client.login(username, password, "Element X Android", null)
client.login(
username = username,
password = password,
initialDeviceName = "Element X Android",
deviceId = null,
)
// Ensure that the user is not already logged in with the same account
ensureNotAlreadyLoggedIn(client)
tryToImportSecretForElementClassicSession(client)
val sessionData = client.session()
.toSessionData(
isTokenValid = true,
@ -162,6 +174,53 @@ class RustMatrixAuthenticationService(
}
}
private suspend fun tryToImportSecretForElementClassicSession(client: Client) {
elementClassicSession
?.takeIf {
// Note: the SDK will also do this check
it.userId.value == client.userId()
}
?.let {
val secrets = it.secrets
val roomKeysVersion = it.roomKeysVersion
if (secrets == null || roomKeysVersion == null) {
Timber.d("No secrets or roomKeysVersion found for Element Classic session ${it.userId}, skipping import")
} else {
Timber.d("Trying to import secrets for Element Classic session ${it.userId}")
runCatchingExceptions {
SecretsBundleWithUserId.fromStr(
userId = it.userId.value,
bundle = secrets,
backupInfo = roomKeysVersion,
).use { secretsBundle ->
client.encryption().importSecretsBundle(secretsBundle)
}
}.onFailure { failure ->
Timber.e(failure, "Failed to import secrets for Element Classic session ${it.userId}")
}
}
}
}
override fun doSecretsContainBackupKey(
userId: UserId,
secrets: String,
backupInfo: String,
): Boolean {
return try {
SecretsBundleWithUserId.fromStr(
userId = userId.value,
bundle = secrets,
backupInfo = backupInfo,
).use { secretsBundle ->
secretsBundle.containsBackupKey()
}
} catch (failure: Exception) {
Timber.e(failure, "Failed to parse secrets for Element Classic session $userId")
false
}
}
override suspend fun importCreatedSession(externalSession: ExternalSession): Result<SessionId> =
withContext(coroutineDispatchers.io) {
runCatchingExceptions {
@ -233,6 +292,10 @@ class RustMatrixAuthenticationService(
}
}
override fun setElementClassicSession(session: ElementClassicSession?) {
elementClassicSession = session
}
/**
* callbackUrl should be the uriRedirect from OidcClientMetadata (with all the parameters).
*/
@ -241,14 +304,15 @@ class RustMatrixAuthenticationService(
runCatchingExceptions {
val client = currentClient ?: error("You need to call `setHomeserver()` first")
val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first")
client.loginWithOidcCallback(callbackUrl)
client.loginWithOidcCallback(
callbackUrl = callbackUrl,
)
// Free the pending data since we won't use it to abort the flow anymore
pendingOAuthAuthorizationData?.close()
pendingOAuthAuthorizationData = null
// Ensure that the user is not already logged in with the same account
ensureNotAlreadyLoggedIn(client)
tryToImportSecretForElementClassicSession(client)
val sessionData = client.session().toSessionData(
isTokenValid = true,
loginType = LoginType.OIDC,

View file

@ -13,6 +13,7 @@ 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.HomeserverCapabilitiesProvider
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
@ -90,4 +91,9 @@ object SessionMatrixModule {
fun providesSpaceService(matrixClient: MatrixClient): SpaceService {
return matrixClient.spaceService
}
@Provides
fun providesHomeserverCapabilitiesProvider(matrixClient: MatrixClient): HomeserverCapabilitiesProvider {
return matrixClient.homeserverCapabilities()
}
}

View file

@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
import io.element.android.libraries.matrix.api.room.threads.ThreadsListService
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
@ -46,8 +47,10 @@ import io.element.android.libraries.matrix.impl.room.knock.RustKnockRequest
import io.element.android.libraries.matrix.impl.room.location.liveLocationSharesFlow
import io.element.android.libraries.matrix.impl.room.location.timedByExpiry
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
import io.element.android.libraries.matrix.impl.room.threads.RustThreadsListService
import io.element.android.libraries.matrix.impl.roomdirectory.map
import io.element.android.libraries.matrix.impl.timeline.RustTimeline
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
import io.element.android.libraries.matrix.impl.util.MessageEventContent
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver
@ -147,6 +150,12 @@ class JoinedRustRoom(
override val liveTimeline = liveInnerTimeline.map(mode = Timeline.Mode.Live)
override val threadsListService: ThreadsListService = RustThreadsListService(
inner = innerRoom.threadListService(),
contentMapper = TimelineEventContentMapper(),
roomCoroutineScope = roomCoroutineScope,
)
override val syncUpdateFlow = flow {
var counter = 0L
liveTimeline.onSyncedEventReceived.collect {
@ -530,6 +539,7 @@ class JoinedRustRoom(
override fun destroy() {
baseRoom.destroy()
liveInnerTimeline.destroy()
threadsListService.destroy()
Timber.d("Room $roomId destroyed")
}

View file

@ -0,0 +1,157 @@
/*
* 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.room.threads
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.UserId
import io.element.android.libraries.matrix.api.room.threads.ThreadListItem
import io.element.android.libraries.matrix.api.room.threads.ThreadListItemEvent
import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus
import io.element.android.libraries.matrix.api.room.threads.ThreadsListService
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
import io.element.android.libraries.matrix.impl.timeline.item.event.map
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import org.matrix.rustcomponents.sdk.ThreadListEntriesListener
import org.matrix.rustcomponents.sdk.ThreadListPaginationStateListener
import org.matrix.rustcomponents.sdk.ThreadListUpdate
import uniffi.matrix_sdk_ui.ThreadListPaginationState
import org.matrix.rustcomponents.sdk.ThreadListService as InnerThreadListService
class RustThreadsListService(
private val inner: InnerThreadListService,
private val roomCoroutineScope: CoroutineScope,
private val contentMapper: TimelineEventContentMapper = TimelineEventContentMapper(),
) : ThreadsListService {
private var itemSubscriptionJob: Job? = null
private val items = MutableStateFlow<List<ThreadListItem>>(emptyList())
override fun subscribeToItemUpdates(): Flow<List<ThreadListItem>> {
if (itemSubscriptionJob?.isActive != true) {
itemSubscriptionJob = doSubscribeToItemUpdates()
}
return items
}
private fun doSubscribeToItemUpdates(): Job {
val updatesFlow = mxCallbackFlow {
inner.subscribeToItemsUpdates(object : ThreadListEntriesListener {
override fun onUpdate(diff: List<ThreadListUpdate>) {
trySend(diff)
}
})
}
return updatesFlow
.onStart { items.value = inner.items().map { it.map(contentMapper) } }
.onEach { diff ->
val updated = items.value.toMutableList()
updated.apply(diff, contentMapper)
items.value = updated
}
.launchIn(roomCoroutineScope)
}
override fun subscribeToPaginationUpdates(): Flow<ThreadListPaginationStatus> {
return mxCallbackFlow {
inner.subscribeToPaginationStateUpdates(object : ThreadListPaginationStateListener {
override fun onUpdate(state: ThreadListPaginationState) {
trySend(state.map())
}
}).also {
// Send the initial state
trySend(inner.paginationState().map())
}
}
}
override suspend fun paginate(): Result<Unit> = runCatchingExceptions {
inner.paginate()
}
override suspend fun reset(): Result<Unit> = runCatchingExceptions {
inner.reset()
}
override fun destroy() {
itemSubscriptionJob?.cancel()
inner.destroy()
}
}
private fun MutableList<ThreadListItem>.apply(
diff: List<ThreadListUpdate>,
contentMapper: TimelineEventContentMapper
) {
for (diffItem in diff) {
when (diffItem) {
is ThreadListUpdate.Append -> {
val newItems = diffItem.values.map { it.map(contentMapper) }
addAll(newItems)
}
ThreadListUpdate.Clear -> clear()
is ThreadListUpdate.Insert -> {
add(diffItem.index.toInt(), diffItem.value.map(contentMapper))
}
ThreadListUpdate.PopBack -> {
removeAt(lastIndex)
}
ThreadListUpdate.PopFront -> {
removeAt(0)
}
is ThreadListUpdate.PushBack -> {
add(diffItem.value.map(contentMapper))
}
is ThreadListUpdate.PushFront -> {
add(0, diffItem.value.map(contentMapper))
}
is ThreadListUpdate.Remove -> {
removeAt(diffItem.index.toInt())
}
is ThreadListUpdate.Reset -> {
clear()
addAll(diffItem.values.map { it.map(contentMapper) })
}
is ThreadListUpdate.Set -> {
set(diffItem.index.toInt(), diffItem.value.map(contentMapper))
}
is ThreadListUpdate.Truncate -> {
subList(diffItem.length.toInt(), size).clear()
}
}
}
}
fun org.matrix.rustcomponents.sdk.ThreadListItem.map(contentMapper: TimelineEventContentMapper): ThreadListItem = ThreadListItem(
rootEvent = rootEvent.map(contentMapper),
latestEvent = latestEvent?.map(contentMapper),
numberOfReplies = numReplies.toLong(),
)
fun org.matrix.rustcomponents.sdk.ThreadListItemEvent.map(contentMapper: TimelineEventContentMapper): ThreadListItemEvent = ThreadListItemEvent(
eventId = EventId(eventId),
senderId = UserId(sender),
isOwn = isOwn,
senderProfile = senderProfile.map(),
content = content?.let(contentMapper::map),
timestamp = timestamp.toLong(),
)
fun ThreadListPaginationState.map(): ThreadListPaginationStatus = when (this) {
is ThreadListPaginationState.Idle -> ThreadListPaginationStatus.Idle(hasMoreToLoad = !endReached)
ThreadListPaginationState.Loading -> ThreadListPaginationStatus.Loading
}

View file

@ -130,6 +130,8 @@ class RustTimeline(
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode is Timeline.Mode.FocusedOnEvent)
)
private val loggerTag = "Timeline($mode)"
init {
when (mode) {
is Timeline.Mode.Live, is Timeline.Mode.FocusedOnEvent -> coroutineScope.fetchMembers()
@ -177,10 +179,11 @@ class RustTimeline(
}
private fun updatePaginationStatus(direction: Timeline.PaginationDirection, update: (Timeline.PaginationStatus) -> Timeline.PaginationStatus) {
when (direction) {
val result = when (direction) {
Timeline.PaginationDirection.BACKWARDS -> backwardPaginationStatus.getAndUpdate(update)
Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus.getAndUpdate(update)
}
Timber.tag(loggerTag).d("updatePaginationStatus $direction: $result")
}
// Use NonCancellable to avoid breaking the timeline when the coroutine is cancelled.
@ -195,12 +198,13 @@ class RustTimeline(
}
}.onFailure { error ->
if (error is TimelineException.CannotPaginate) {
Timber.d("Can't paginate $direction on room ${joinedRoom.roomId} with paginationStatus: ${backwardPaginationStatus.value}")
Timber.tag(loggerTag).d("Can't paginate $direction on room ${joinedRoom.roomId} with paginationStatus: ${backwardPaginationStatus.value}")
} else {
updatePaginationStatus(direction) { it.copy(isPaginating = false) }
Timber.e(error, "Error paginating $direction on room ${joinedRoom.roomId}")
Timber.tag(loggerTag).e(error, "Error paginating $direction on room ${joinedRoom.roomId}")
}
}.onSuccess { hasReachedEnd ->
Timber.tag(loggerTag).d("Finished paginating $direction on room ${joinedRoom.roomId}, hasReachedEnd: $hasReachedEnd")
updatePaginationStatus(direction) { it.copy(isPaginating = false, hasMoreToLoad = !hasReachedEnd) }
}
}
@ -264,7 +268,7 @@ class RustTimeline(
try {
inner.fetchMembers()
} catch (exception: Exception) {
Timber.e(exception, "Error fetching members for room ${joinedRoom.roomId}")
Timber.tag(loggerTag).e(exception, "Error fetching members for room ${joinedRoom.roomId}")
}
}
@ -370,7 +374,7 @@ class RustTimeline(
formattedCaption: String?,
inReplyToEventId: EventId?,
): Result<MediaUploadHandler> {
Timber.d("Sending image ${file.path.hash()}")
Timber.tag(loggerTag).d("Sending image ${file.path.hash()}")
return sendAttachment(listOfNotNull(file, thumbnailFile)) {
inner.sendImage(
params = UploadParameters(
@ -396,7 +400,7 @@ class RustTimeline(
formattedCaption: String?,
inReplyToEventId: EventId?,
): Result<MediaUploadHandler> {
Timber.d("Sending video ${file.path.hash()}")
Timber.tag(loggerTag).d("Sending video ${file.path.hash()}")
return sendAttachment(listOfNotNull(file, thumbnailFile)) {
inner.sendVideo(
params = UploadParameters(
@ -421,7 +425,7 @@ class RustTimeline(
formattedCaption: String?,
inReplyToEventId: EventId?,
): Result<MediaUploadHandler> {
Timber.d("Sending audio ${file.path.hash()}")
Timber.tag(loggerTag).d("Sending audio ${file.path.hash()}")
return sendAttachment(listOf(file)) {
inner.sendAudio(
params = UploadParameters(
@ -445,7 +449,7 @@ class RustTimeline(
formattedCaption: String?,
inReplyToEventId: EventId?,
): Result<MediaUploadHandler> {
Timber.d("Sending file ${file.path.hash()}")
Timber.tag(loggerTag).d("Sending file ${file.path.hash()}")
return sendAttachment(listOf(file)) {
inner.sendFile(
params = UploadParameters(
@ -475,7 +479,7 @@ class RustTimeline(
runCatchingExceptions {
roomContentForwarder.forward(fromTimeline = inner, eventId = eventId, toRoomIds = roomIds)
}.onFailure {
Timber.e(it)
Timber.tag(loggerTag).e(it)
}
}

View file

@ -0,0 +1,64 @@
/*
* 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.impl.fixtures.fakes.FakeFfiHomeserverCapabilities
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.test.runTest
import org.junit.Test
class RustHomeserverCapabilitiesProviderTest {
@Test
fun `refresh calls client refresh`() = runTest {
val refreshLambda = lambdaRecorder<Unit> {}
val provider = createCapabilitiesProvider(
capabilities = FakeFfiHomeserverCapabilities(refresh = refreshLambda),
)
assertThat(provider.refresh().isSuccess).isTrue()
refreshLambda.assertions().isCalledOnce()
}
@Test
fun `refresh fails when client refresh does`() = runTest {
val refreshLambda = lambdaRecorder<Unit> { throw IllegalStateException("Failed to refresh capabilities") }
val provider = createCapabilitiesProvider(
capabilities = FakeFfiHomeserverCapabilities(refresh = refreshLambda),
)
assertThat(provider.refresh().isFailure).isTrue()
refreshLambda.assertions().isCalledOnce()
}
@Test
fun `canChangeDisplayName returns expected value`() = runTest {
val provider = createCapabilitiesProvider(
capabilities = FakeFfiHomeserverCapabilities(canChangeDisplayName = { true }),
)
assertThat(provider.canChangeDisplayName().getOrNull()).isTrue()
}
@Test
fun `canChangeAvatarUrl returns expected value`() = runTest {
val provider = createCapabilitiesProvider(
capabilities = FakeFfiHomeserverCapabilities(canChangeAvatar = { true }),
)
assertThat(provider.canChangeAvatarUrl().getOrNull()).isTrue()
}
@Test
fun `canChangeDisplayName returns failure when client throws`() = runTest {
val provider = createCapabilitiesProvider(
capabilities = FakeFfiHomeserverCapabilities(canChangeDisplayName = { throw IllegalStateException("Failed to get display name capability") }),
)
assert(provider.canChangeDisplayName().isFailure)
}
private fun createCapabilitiesProvider(
capabilities: FakeFfiHomeserverCapabilities = FakeFfiHomeserverCapabilities(),
) = RustHomeserverCapabilitiesProvider(capabilities)
}

View file

@ -17,6 +17,7 @@ import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientDelegate
import org.matrix.rustcomponents.sdk.CreateRoomParameters
import org.matrix.rustcomponents.sdk.Encryption
import org.matrix.rustcomponents.sdk.HomeserverCapabilities
import org.matrix.rustcomponents.sdk.HomeserverLoginDetails
import org.matrix.rustcomponents.sdk.IgnoredUsersListener
import org.matrix.rustcomponents.sdk.NoHandle
@ -50,6 +51,7 @@ class FakeFfiClient(
private val homeserverLoginDetailsResult: () -> HomeserverLoginDetails = { lambdaError() },
private val getStoreSizesResult: () -> StoreSizes = { lambdaError() },
private val createRoomResult: (CreateRoomParameters) -> String = { lambdaError() },
private val homeserverCapabilities: HomeserverCapabilities = FakeFfiHomeserverCapabilities(),
private val closeResult: () -> Unit = {},
) : Client(NoHandle) {
override fun userId(): String = userId
@ -103,5 +105,9 @@ class FakeFfiClient(
return createRoomResult(request)
}
override fun homeserverCapabilities(): HomeserverCapabilities {
return homeserverCapabilities
}
override fun close() = closeResult()
}

View file

@ -0,0 +1,33 @@
/*
* 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.fixtures.fakes
import io.element.android.tests.testutils.lambda.lambdaError
import org.matrix.rustcomponents.sdk.ExtendedProfileFields
import org.matrix.rustcomponents.sdk.HomeserverCapabilities
import org.matrix.rustcomponents.sdk.NoHandle
class FakeFfiHomeserverCapabilities(
private val refresh: () -> Unit = { lambdaError() },
private val canChangeDisplayName: () -> Boolean = { lambdaError() },
private val canChangeAvatar: () -> Boolean = { lambdaError() },
private val canChangePassword: () -> Boolean = { lambdaError() },
private val canChangeThirdPartyIds: () -> Boolean = { lambdaError() },
private val canGetLoginToken: () -> Boolean = { lambdaError() },
private val forgetsRoomWhenLeaving: () -> Boolean = { lambdaError() },
private val extendedProfileFields: () -> ExtendedProfileFields = { lambdaError() },
) : HomeserverCapabilities(NoHandle) {
override suspend fun refresh() = refresh.invoke()
override suspend fun canChangeDisplayname(): Boolean = canChangeDisplayName.invoke()
override suspend fun canChangeAvatar(): Boolean = canChangeAvatar.invoke()
override suspend fun canChangePassword(): Boolean = canChangePassword.invoke()
override suspend fun canChangeThirdpartyIds(): Boolean = canChangeThirdPartyIds.invoke()
override suspend fun canGetLoginToken(): Boolean = canGetLoginToken.invoke()
override suspend fun forgetsRoomWhenLeaving(): Boolean = forgetsRoomWhenLeaving.invoke()
override suspend fun extendedProfileFields(): ExtendedProfileFields = extendedProfileFields.invoke()
}

View file

@ -0,0 +1,58 @@
/*
* 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.fixtures.fakes
import org.matrix.rustcomponents.sdk.NoHandle
import org.matrix.rustcomponents.sdk.TaskHandle
import org.matrix.rustcomponents.sdk.ThreadListEntriesListener
import org.matrix.rustcomponents.sdk.ThreadListItem
import org.matrix.rustcomponents.sdk.ThreadListPaginationStateListener
import org.matrix.rustcomponents.sdk.ThreadListService
import org.matrix.rustcomponents.sdk.ThreadListUpdate
import uniffi.matrix_sdk_ui.ThreadListPaginationState
class FakeFfiThreadListService(
private val subscribeToItemsUpdates: (ThreadListEntriesListener) -> TaskHandle = { FakeFfiTaskHandle() },
private val subscribeToPaginationStateUpdates: (ThreadListPaginationStateListener) -> TaskHandle = { FakeFfiTaskHandle() },
private val items: () -> List<ThreadListItem> = { emptyList() },
private val paginationState: () -> ThreadListPaginationState = { ThreadListPaginationState.Idle(endReached = false) },
private val paginate: suspend () -> Unit = {},
private val reset: suspend () -> Unit = {},
private val destroy: () -> Unit = {},
) : ThreadListService(NoHandle) {
private var itemsListener: ThreadListEntriesListener? = null
private var paginationStateListener: ThreadListPaginationStateListener? = null
override fun subscribeToItemsUpdates(listener: ThreadListEntriesListener): TaskHandle {
itemsListener = listener
return subscribeToItemsUpdates.invoke(listener)
}
override fun subscribeToPaginationStateUpdates(listener: ThreadListPaginationStateListener): TaskHandle {
paginationStateListener = listener
return subscribeToPaginationStateUpdates.invoke(listener)
}
override fun items(): List<ThreadListItem> = items.invoke()
override fun paginationState(): ThreadListPaginationState = paginationState.invoke()
override suspend fun paginate() = paginate.invoke()
override suspend fun reset() = reset.invoke()
override fun destroy() = destroy.invoke()
fun emitUpdates(updates: List<ThreadListUpdate>) {
itemsListener?.onUpdate(updates)
}
fun emitPaginationState(state: ThreadListPaginationState) {
paginationStateListener?.onUpdate(state)
}
}

View file

@ -0,0 +1,143 @@
/*
* 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.room.threads
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustTimelineItemContentMsgLike
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTaskHandle
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiThreadListService
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_TIMESTAMP
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.matrix.rustcomponents.sdk.ProfileDetails
import org.matrix.rustcomponents.sdk.TaskHandle
import org.matrix.rustcomponents.sdk.ThreadListEntriesListener
import org.matrix.rustcomponents.sdk.ThreadListItem
import org.matrix.rustcomponents.sdk.ThreadListItemEvent
import org.matrix.rustcomponents.sdk.ThreadListPaginationStateListener
import org.matrix.rustcomponents.sdk.ThreadListUpdate
import uniffi.matrix_sdk_ui.ThreadListPaginationState
@OptIn(ExperimentalCoroutinesApi::class)
class RustThreadsListServiceTest {
@Test
fun `subscribing to item updates calls the FFI method and allows retrieving new items`() = runTest {
val subscribeToItemsUpdatesRecorder = lambdaRecorder<ThreadListEntriesListener, TaskHandle> { FakeFfiTaskHandle() }
val inner = FakeFfiThreadListService(subscribeToItemsUpdates = subscribeToItemsUpdatesRecorder)
val service = createThreadsListService(inner = inner)
service.subscribeToItemUpdates().test {
assertThat(awaitItem()).isEmpty()
runCurrent()
subscribeToItemsUpdatesRecorder.assertions().isCalledOnce()
inner.emitUpdates(listOf(aRustThreadListUpdate()))
assertThat(awaitItem()).isNotEmpty()
}
}
@Suppress("UnusedFlow")
@Test
fun `subscribing to item updates twice only calls the FFI method once`() = runTest {
val subscribeToItemsUpdatesRecorder = lambdaRecorder<ThreadListEntriesListener, TaskHandle> { FakeFfiTaskHandle() }
val inner = FakeFfiThreadListService(subscribeToItemsUpdates = subscribeToItemsUpdatesRecorder)
val service = createThreadsListService(inner = inner)
service.subscribeToItemUpdates()
service.subscribeToItemUpdates()
runCurrent()
subscribeToItemsUpdatesRecorder.assertions().isCalledOnce()
}
@Test
fun `subscribing to pagination updates calls the FFI method and allows retrieving new items`() = runTest {
val subscribeToPaginationUpdatesRecorder = lambdaRecorder<ThreadListPaginationStateListener, TaskHandle> { FakeFfiTaskHandle() }
val inner = FakeFfiThreadListService(subscribeToPaginationStateUpdates = subscribeToPaginationUpdatesRecorder)
val service = createThreadsListService(inner = inner)
service.subscribeToPaginationUpdates().test {
assertThat(awaitItem()).isEqualTo(ThreadListPaginationStatus.Idle(hasMoreToLoad = true))
runCurrent()
subscribeToPaginationUpdatesRecorder.assertions().isCalledOnce()
inner.emitPaginationState(ThreadListPaginationState.Loading)
assertThat(awaitItem()).isEqualTo(ThreadListPaginationStatus.Loading)
}
}
@Test
fun `paginate calls the FFI method`() = runTest {
val paginateRecorder = lambdaRecorder<Unit> {}
val inner = FakeFfiThreadListService(paginate = paginateRecorder)
val service = createThreadsListService(inner = inner)
service.paginate()
paginateRecorder.assertions().isCalledOnce()
}
@Test
fun `reset calls the FFI method`() = runTest {
val resetRecorder = lambdaRecorder<Unit> {}
val inner = FakeFfiThreadListService(reset = resetRecorder)
val service = createThreadsListService(inner = inner)
service.reset()
resetRecorder.assertions().isCalledOnce()
}
@Test
fun `destroy calls the FFI method`() = runTest {
val destroyRecorder = lambdaRecorder<Unit> {}
val inner = FakeFfiThreadListService(destroy = destroyRecorder)
val service = createThreadsListService(inner = inner)
service.destroy()
destroyRecorder.assertions().isCalledOnce()
}
private fun TestScope.createThreadsListService(
inner: FakeFfiThreadListService = FakeFfiThreadListService(),
) = RustThreadsListService(
inner = inner,
roomCoroutineScope = backgroundScope,
)
private fun aRustThreadListUpdate() = ThreadListUpdate.Append(
values = listOf(
ThreadListItem(
rootEvent = ThreadListItemEvent(
eventId = AN_EVENT_ID.value,
timestamp = A_TIMESTAMP.toULong(),
sender = A_USER_ID.value,
senderProfile = ProfileDetails.Pending,
isOwn = true,
content = aRustTimelineItemContentMsgLike(),
),
numReplies = 0u,
latestEvent = null,
)
),
)
}

View file

@ -0,0 +1,20 @@
/*
* 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.test
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
class FakeHomeserverCapabilitiesProvider(
private val refresh: () -> Result<Unit> = { Result.success(Unit) },
private val canChangeDisplayName: () -> Result<Boolean> = { Result.success(true) },
private val canChangeAvatarUrl: () -> Result<Boolean> = { Result.success(true) },
) : HomeserverCapabilitiesProvider {
override suspend fun refresh(): Result<Unit> = refresh.invoke()
override suspend fun canChangeDisplayName(): Result<Boolean> = canChangeDisplayName.invoke()
override suspend fun canChangeAvatarUrl(): Result<Boolean> = canChangeAvatarUrl.invoke()
}

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.test
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes
import io.element.android.libraries.matrix.api.core.DeviceId
@ -84,6 +85,7 @@ class FakeMatrixClient(
override val roomDirectoryService: RoomDirectoryService = FakeRoomDirectoryService(),
override val mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(),
override val roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(),
private val homeserverCapabilitiesProvider: FakeHomeserverCapabilitiesProvider = FakeHomeserverCapabilitiesProvider(),
private val accountManagementUrlResult: (AccountManagementAction?) -> Result<String?> = { lambdaError() },
private val resolveRoomAliasResult: (RoomAlias) -> Result<Optional<ResolvedRoomAlias>> = {
Result.success(
@ -384,4 +386,8 @@ class FakeMatrixClient(
override suspend fun resetWellKnownConfig(): Result<Unit> {
return resetWellKnownConfigLambda()
}
override fun homeserverCapabilities(): HomeserverCapabilitiesProvider {
return homeserverCapabilitiesProvider
}
}

View file

@ -9,6 +9,7 @@
package io.element.android.libraries.matrix.test.auth
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.auth.ElementClassicSession
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.auth.OidcDetails
@ -17,6 +18,7 @@ import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
@ -32,6 +34,8 @@ class FakeMatrixAuthenticationService(
lambdaRecorder<MatrixQrCodeLoginData, (QrCodeLoginStep) -> Unit, Result<SessionId>> { _, _ -> Result.success(A_SESSION_ID) },
private val importCreatedSessionLambda: (ExternalSession) -> Result<SessionId> = { lambdaError() },
private val setHomeserverResult: (String) -> Result<MatrixHomeServerDetails> = { lambdaError() },
private val setElementClassicSessionResult: (ElementClassicSession?) -> Unit = { lambdaError() },
private val doSecretsContainBackupKeyResult: (UserId, String, String) -> Boolean = { _, _, _ -> lambdaError() },
) : MatrixAuthenticationService {
private var oidcError: Throwable? = null
private var oidcCancelError: Throwable? = null
@ -108,4 +112,12 @@ class FakeMatrixAuthenticationService(
fun givenMatrixClient(matrixClient: MatrixClient) {
this.matrixClient = matrixClient
}
override fun setElementClassicSession(session: ElementClassicSession?) {
setElementClassicSessionResult(session)
}
override fun doSecretsContainBackupKey(userId: UserId, secrets: String, backupInfo: String): Boolean {
return doSecretsContainBackupKeyResult(userId, secrets, backupInfo)
}
}

View file

@ -35,6 +35,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.room.threads.FakeThreadsListService
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
@ -56,6 +57,7 @@ class FakeJoinedRoom(
override val roomNotificationSettingsStateFlow: StateFlow<RoomNotificationSettingsState> =
MutableStateFlow(RoomNotificationSettingsState.Unknown),
override val knockRequestsFlow: Flow<List<KnockRequest>> = MutableStateFlow(emptyList()),
override val threadsListService: FakeThreadsListService = FakeThreadsListService(),
private val roomNotificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
private var createTimelineResult: (CreateTimelineParams) -> Result<Timeline> = { lambdaError() },
private val editMessageLambda: (EventId, String, String?, List<IntentionalMention>) -> Result<Unit> = { _, _, _, _ -> lambdaError() },

View file

@ -0,0 +1,48 @@
/*
* 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.test.room.threads
import io.element.android.libraries.matrix.api.room.threads.ThreadListItem
import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus
import io.element.android.libraries.matrix.api.room.threads.ThreadsListService
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class FakeThreadsListService(
private val items: MutableStateFlow<List<ThreadListItem>> = MutableStateFlow(emptyList()),
private val paginationStatus: MutableStateFlow<ThreadListPaginationStatus> = MutableStateFlow(ThreadListPaginationStatus.Idle(hasMoreToLoad = true)),
private val subscribeToItemUpdates: () -> Flow<List<ThreadListItem>> = { items },
private val subscribeToPaginationUpdates: () -> Flow<ThreadListPaginationStatus> = { paginationStatus },
private val paginate: suspend () -> Result<Unit> = { Result.success(Unit) },
private val reset: suspend () -> Result<Unit> = { Result.success(Unit) },
private val destroy: () -> Unit = {},
) : ThreadsListService {
override fun subscribeToItemUpdates(): Flow<List<ThreadListItem>> {
return subscribeToItemUpdates.invoke()
}
override fun subscribeToPaginationUpdates(): Flow<ThreadListPaginationStatus> {
return subscribeToPaginationUpdates.invoke()
}
override suspend fun paginate(): Result<Unit> {
return paginate.invoke()
}
override suspend fun reset(): Result<Unit> {
return reset.invoke()
}
override fun destroy() {
return destroy.invoke()
}
suspend fun emit(items: List<ThreadListItem>) {
this.items.emit(items)
}
}

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@ -22,9 +23,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
@ -33,6 +38,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.R
@ -48,10 +54,23 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun CreateDmConfirmationBottomSheet(
matrixUser: MatrixUser,
enableKeyShareOnInvite: Boolean,
isUserIdentityUnknown: Boolean,
onSendInvite: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val titleContent = if (enableKeyShareOnInvite && isUserIdentityUnknown) {
stringResource(R.string.screen_bottom_sheet_create_dm_unknown_user_title)
} else {
stringResource(R.string.screen_bottom_sheet_create_dm_title)
}
val descriptionContent = if (enableKeyShareOnInvite && isUserIdentityUnknown) {
stringResource(R.string.screen_bottom_sheet_create_dm_unknown_user_content)
} else {
stringResource(R.string.screen_bottom_sheet_create_dm_message, matrixUser.getFullName())
}
ModalBottomSheet(
modifier = modifier,
onDismissRequest = onDismiss,
@ -63,47 +82,95 @@ fun CreateDmConfirmationBottomSheet(
.padding(top = 24.dp, bottom = 16.dp, start = 16.dp, end = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Avatar(
avatarData = matrixUser.getAvatarData(AvatarSize.DmCreationConfirmation),
avatarType = AvatarType.User,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.screen_bottom_sheet_create_dm_title),
style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.screen_bottom_sheet_create_dm_message, matrixUser.getFullName()),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(40.dp))
Button(
modifier = Modifier.fillMaxWidth(),
onClick = onSendInvite,
leadingIcon = IconSource.Vector(CompoundIcons.UserAdd()),
text = stringResource(R.string.screen_bottom_sheet_create_dm_confirmation_button_title),
)
Spacer(modifier = Modifier.height(16.dp))
TextButton(
modifier = Modifier.fillMaxWidth(),
onClick = onDismiss,
text = stringResource(CommonStrings.action_cancel),
)
if (isUserIdentityUnknown) {
IconTitleSubtitleMolecule(
modifier = Modifier.padding(
bottom = 16.dp,
start = 16.dp,
end = 16.dp,
),
title = titleContent,
subTitle = descriptionContent,
iconStyle = BigIcon.Style.Default(CompoundIcons.UserAddSolid()),
)
MatrixUserRow(matrixUser)
Spacer(modifier = Modifier.height(32.dp))
ButtonRowMolecule(
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
OutlinedButton(
modifier = Modifier.weight(1f),
text = stringResource(CommonStrings.action_cancel),
onClick = onDismiss
)
Button(
modifier = Modifier.weight(1f),
text = stringResource(CommonStrings.action_continue),
onClick = onSendInvite
)
}
Spacer(modifier = Modifier.height(32.dp))
} else {
Avatar(
avatarData = matrixUser.getAvatarData(AvatarSize.DmCreationConfirmation),
avatarType = AvatarType.User,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = titleContent,
style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = descriptionContent,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(40.dp))
Button(
modifier = Modifier.fillMaxWidth(),
onClick = onSendInvite,
leadingIcon = IconSource.Vector(CompoundIcons.UserAdd()),
text = stringResource(R.string.screen_bottom_sheet_create_dm_confirmation_button_title),
)
Spacer(modifier = Modifier.height(16.dp))
TextButton(
modifier = Modifier.fillMaxWidth(),
onClick = onDismiss,
text = stringResource(CommonStrings.action_cancel),
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun CreateDmConfirmationBottomSheetPreview(@PreviewParameter(MatrixUserProvider::class) matrixUser: MatrixUser) = ElementPreview {
internal fun CreateDmConfirmationBottomSheetPreview(@PreviewParameter(
CreateDmConfirmationBottomSheetStateProvider::class
) state: CreateDmConfirmationBottomSheetState) = ElementPreview {
CreateDmConfirmationBottomSheet(
matrixUser = matrixUser,
matrixUser = state.matrixUser,
enableKeyShareOnInvite = state.enableKeyShareOnInvite,
isUserIdentityUnknown = state.isUserIdentityUnknown,
onSendInvite = {},
onDismiss = {},
)
}
data class CreateDmConfirmationBottomSheetState(
val matrixUser: MatrixUser,
val enableKeyShareOnInvite: Boolean,
val isUserIdentityUnknown: Boolean,
)
class CreateDmConfirmationBottomSheetStateProvider : PreviewParameterProvider<CreateDmConfirmationBottomSheetState> {
override val values = sequenceOf(
CreateDmConfirmationBottomSheetState(matrixUser = aMatrixUser(), enableKeyShareOnInvite = false, isUserIdentityUnknown = false),
CreateDmConfirmationBottomSheetState(matrixUser = aMatrixUser(), enableKeyShareOnInvite = true, isUserIdentityUnknown = false),
CreateDmConfirmationBottomSheetState(matrixUser = aMatrixUser(), enableKeyShareOnInvite = true, isUserIdentityUnknown = true),
)
}

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -34,51 +35,34 @@ import io.element.android.libraries.matrix.ui.model.getBestName
@Composable
fun MatrixUserHeader(
matrixUser: MatrixUser?,
modifier: Modifier = Modifier,
// TODO handle click on this item, to let the user be able to update their profile.
// onClick: () -> Unit,
) {
if (matrixUser == null) {
MatrixUserHeaderPlaceholder(modifier = modifier)
} else {
MatrixUserHeaderContent(
matrixUser = matrixUser,
modifier = modifier,
// onClick = onClick
)
}
}
@Composable
private fun MatrixUserHeaderContent(
matrixUser: MatrixUser,
modifier: Modifier = Modifier,
// onClick: () -> Unit,
) {
Row(
modifier = modifier
// .clickable(onClick = onClick)
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
.padding(horizontal = 16.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
modifier = Modifier
.padding(vertical = 12.dp),
.padding(vertical = 7.dp),
avatarData = matrixUser.getAvatarData(size = AvatarSize.UserPreference),
avatarType = AvatarType.User,
)
Spacer(modifier = Modifier.width(16.dp))
Spacer(modifier = Modifier.width(13.dp))
Column(
modifier = Modifier.weight(1f)
modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
// Name
Text(
modifier = Modifier.clipToBounds(),
text = matrixUser.getBestName(),
maxLines = 1,
style = ElementTheme.typography.fontHeadingSmMedium,
style = ElementTheme.typography.fontHeadingMdRegular,
overflow = TextOverflow.Ellipsis,
color = ElementTheme.colors.textPrimary,
)

View file

@ -1,64 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.placeholderBackground
@Composable
fun MatrixUserHeaderPlaceholder(
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.padding(vertical = 12.dp)
.size(AvatarSize.UserPreference.dp)
.background(color = ElementTheme.colors.placeholderBackground, shape = CircleShape)
)
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.weight(1f)
) {
PlaceholderAtom(width = 80.dp, height = 7.dp)
Spacer(modifier = Modifier.height(16.dp))
PlaceholderAtom(width = 180.dp, height = 6.dp)
}
}
}
@PreviewsDayNight
@Composable
internal fun MatrixUserHeaderPlaceholderPreview() = ElementPreview {
MatrixUserHeaderPlaceholder()
}

View file

@ -20,15 +20,6 @@ open class MatrixUserProvider : PreviewParameterProvider<MatrixUser> {
)
}
open class MatrixUserWithNullProvider : PreviewParameterProvider<MatrixUser?> {
override val values: Sequence<MatrixUser?>
get() = sequenceOf(
aMatrixUser(),
aMatrixUser(displayName = null),
null,
)
}
open class MatrixUserWithAvatarProvider : PreviewParameterProvider<MatrixUser?> {
override val values: Sequence<MatrixUser?>
get() = sequenceOf(

View file

@ -11,6 +11,8 @@ package io.element.android.libraries.matrix.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -23,12 +25,14 @@ fun MatrixUserRow(
matrixUser: MatrixUser,
modifier: Modifier = Modifier,
avatarSize: AvatarSize = AvatarSize.UserListItem,
verticalSpaceWidth: Dp = 12.dp,
trailingContent: @Composable (() -> Unit)? = null,
) = UserRow(
avatarData = matrixUser.getAvatarData(avatarSize),
name = matrixUser.getBestName(),
subtext = if (matrixUser.displayName.isNullOrEmpty()) null else matrixUser.userId.value,
modifier = modifier,
verticalSpaceWidth = verticalSpaceWidth,
trailingContent = trailingContent,
)

View file

@ -10,13 +10,16 @@ package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.components.avatar.Avatar
@ -31,22 +34,22 @@ internal fun UserRow(
subtext: String?,
modifier: Modifier = Modifier,
enabled: Boolean = true,
verticalSpaceWidth: Dp = 12.dp,
trailingContent: @Composable (() -> Unit)? = null,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 12.dp),
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Avatar(
avatarData = avatarData,
avatarType = AvatarType.User,
)
Spacer(modifier = Modifier.width(verticalSpaceWidth))
Column(
modifier = Modifier
.padding(start = 12.dp)
.weight(1f),
modifier = Modifier.weight(1f),
) {
// Name
Text(

View file

@ -23,7 +23,7 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaViewState
import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
import io.element.android.libraries.ui.strings.CommonStrings
import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage
import me.saket.telephoto.zoomable.coil3.ZoomableAsyncImage
import me.saket.telephoto.zoomable.rememberZoomableImageState
@Composable

View file

@ -120,11 +120,25 @@ class MediaViewerDataSource(
*/
private fun buildMediaViewerPageList(groupedItems: List<MediaItem>) = buildList {
// Filter out DateSeparator items, we do not need them for the media viewer
val groupedItemsNoDateSeparator = groupedItems.filterNot { it is MediaItem.DateSeparator }
pagerKeysHandler.accept(groupedItemsNoDateSeparator)
groupedItemsNoDateSeparator.forEach { mediaItem ->
val itemsNoDateSeparator = groupedItems.filterNot { it is MediaItem.DateSeparator }
// Separate loading indicators and media events
val loadingIndicators = itemsNoDateSeparator.filterIsInstance<MediaItem.LoadingIndicator>()
val mediaEvents = itemsNoDateSeparator.filterIsInstance<MediaItem.Event>()
// Determine backward and forward loading indicators
val backwardLoading = loadingIndicators.find { it.direction == Timeline.PaginationDirection.BACKWARDS }
val forwardLoading = loadingIndicators.find { it.direction == Timeline.PaginationDirection.FORWARDS }
// Build ordered list: backward loading, media events (oldest first), forward loading
// Media events are currently newest first, reverse to get oldest first
val orderedEvents = mediaEvents.reversed()
// Create new list of MediaItem in order: backwardLoading, orderedEvents, forwardLoading
val orderedItems = buildList {
backwardLoading?.let { add(it) }
addAll(orderedEvents)
forwardLoading?.let { add(it) }
}
pagerKeysHandler.accept(orderedItems)
orderedItems.forEach { mediaItem ->
when (mediaItem) {
is MediaItem.DateSeparator -> Unit
is MediaItem.Event -> {
val sourceUrl = mediaItem.mediaSource().safeUrl
val localMedia = localMediaStates.getOrPut(sourceUrl) {
@ -148,6 +162,7 @@ class MediaViewerDataSource(
pagerKey = pagerKeysHandler.getKey(mediaItem),
)
)
is MediaItem.DateSeparator -> Unit // already filtered out
}
}
}.toImmutableList()

View file

@ -179,15 +179,19 @@ class MediaViewerPresenter(
) {
val isRenderingLoadingBackward by remember {
derivedStateOf {
currentIndex.intValue == data.value.lastIndex &&
currentIndex.intValue == 0 &&
data.value.size > 1 &&
data.value.lastOrNull() is MediaViewerPageData.Loading
data.value.firstOrNull() is MediaViewerPageData.Loading &&
(data.value.firstOrNull() as? MediaViewerPageData.Loading)?.direction == Timeline.PaginationDirection.BACKWARDS
}
}
if (isRenderingLoadingBackward) {
LaunchedEffect(Unit) {
// Observe the loading data vanishing
snapshotFlow { data.value.lastOrNull() is MediaViewerPageData.Loading }
snapshotFlow {
val first = data.value.firstOrNull()
first is MediaViewerPageData.Loading && first.direction == Timeline.PaginationDirection.BACKWARDS
}
.distinctUntilChanged()
.filter { !it }
.onEach { showNoMoreItemsSnackbar() }
@ -203,15 +207,19 @@ class MediaViewerPresenter(
) {
val isRenderingLoadingForward by remember {
derivedStateOf {
currentIndex.intValue == 0 &&
currentIndex.intValue == data.value.lastIndex &&
data.value.size > 1 &&
data.value.firstOrNull() is MediaViewerPageData.Loading
data.value.lastOrNull() is MediaViewerPageData.Loading &&
(data.value.lastOrNull() as? MediaViewerPageData.Loading)?.direction == Timeline.PaginationDirection.FORWARDS
}
}
if (isRenderingLoadingForward) {
LaunchedEffect(Unit) {
// Observe the loading data vanishing
snapshotFlow { data.value.firstOrNull() is MediaViewerPageData.Loading }
snapshotFlow {
val last = data.value.lastOrNull()
last is MediaViewerPageData.Loading && last.direction == Timeline.PaginationDirection.FORWARDS
}
.distinctUntilChanged()
.filter { !it }
.onEach { showNoMoreItemsSnackbar() }

View file

@ -29,6 +29,14 @@ import io.element.android.libraries.mediaviewer.impl.details.aMediaDeleteConfirm
import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState
import kotlinx.collections.immutable.toImmutableList
private const val LONG_CAPTION = "This is a very long caption that should be scrollable in the media viewer. " +
"It contains multiple lines of text to demonstrate the scrolling behavior. " +
"Line 1: Lorem ipsum dolor sit amet, consectetur adipiscing elit. " +
"Line 2: Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. " +
"Line 3: Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris. " +
"Line 4: Duis aute irure dolor in reprehenderit in voluptate velit esse cillum. " +
"Line 5: Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia."
open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState> {
override val values: Sequence<MediaViewerState>
get() = sequenceOf(
@ -170,6 +178,22 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
)
)
),
anImageMediaInfo(
senderName = "Alice",
dateSent = "21 NOV, 2024",
caption = LONG_CAPTION,
).let {
aMediaViewerState(
listOf(
aMediaViewerPageData(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
)
)
},
)
}

View file

@ -17,18 +17,24 @@ import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@ -39,14 +45,17 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@ -69,6 +78,7 @@ import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.hasCompactHeightWindowSize
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.media.MediaSource
@ -102,8 +112,9 @@ fun MediaViewerView(
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
var showOverlay by remember { mutableStateOf(true) }
val defaultBottomPaddingInPixels = if (LocalInspectionMode.current) 303 else 0
val currentData = state.listData.getOrNull(state.currentIndex)
val defaultBottomPaddingInPixels = if (LocalInspectionMode.current && !hasCompactHeightWindowSize()) 303 else 0
BackHandler { onBackClick() }
Scaffold(
modifier,
@ -153,10 +164,11 @@ fun MediaViewerView(
// So we need to update this value only when the `settledPage` value changes. It seems like a bug that needs to be fixed in Compose.
page == pagerState.settledPage
}
val navigationBarPadding = WindowInsets.navigationBars.getBottom(LocalDensity.current)
MediaViewerPage(
isDisplayed = isDisplayed,
showOverlay = showOverlay,
bottomPaddingInPixels = bottomPaddingInPixels,
bottomPaddingInPixels = (bottomPaddingInPixels - navigationBarPadding).coerceAtLeast(0),
data = dataForPage,
textFileViewer = textFileViewer,
onDismiss = onBackClick,
@ -175,9 +187,7 @@ fun MediaViewerView(
// Bottom bar
AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) {
Box(
modifier = Modifier
.fillMaxSize()
.navigationBarsPadding()
modifier = Modifier.fillMaxSize()
) {
MediaViewerBottomBar(
modifier = Modifier.align(Alignment.BottomCenter),
@ -538,19 +548,46 @@ private fun MediaViewerBottomBar(
if (showDivider) {
HorizontalDivider()
}
Text(
val scrollState = rememberScrollState()
val showBottomShadow by remember { derivedStateOf { scrollState.value < scrollState.maxValue } }
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
text = caption,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyLgRegular,
)
.heightIn(max = if (hasCompactHeightWindowSize()) maxCaptionHeightLandscape else maxCaptionHeightPortrait),
) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.verticalScroll(scrollState)
.navigationBarsPadding(),
text = caption,
style = ElementTheme.typography.fontBodyLgRegular,
)
if (showBottomShadow) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.align(Alignment.BottomCenter)
.background(
brush = Brush.verticalGradient(
colors = listOf(
Color.Transparent,
bgCanvasWithTransparency,
),
),
),
)
}
}
}
}
}
private val maxCaptionHeightPortrait = 200.dp
private val maxCaptionHeightLandscape = 128.dp
@Composable
private fun ThumbnailView(
thumbnailSource: MediaSource?,
@ -604,3 +641,14 @@ internal fun MediaViewerViewPreview(@PreviewParameter(MediaViewerStateProvider::
onBackClick = {},
)
}
@Preview(device = "${Devices.PHONE}, orientation=landscape")
@Composable
internal fun MediaViewerViewLandscapePreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) = ElementPreviewDark {
MediaViewerView(
state = state,
audioFocus = null,
textFileViewer = { _, _ -> },
onBackClick = {},
)
}

View file

@ -593,20 +593,20 @@ class MediaViewerPresenterTest {
if (mode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) {
GroupedMediaItems(
imageAndVideoItems = persistentListOf(),
fileItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator),
fileItems = persistentListOf(aBackwardLoadingIndicator, anImage, aForwardLoadingIndicator),
)
} else {
GroupedMediaItems(
imageAndVideoItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator),
imageAndVideoItems = persistentListOf(aBackwardLoadingIndicator, anImage, aForwardLoadingIndicator),
fileItems = persistentListOf(),
)
}
)
)
val updatedState = awaitItem()
// User navigate to the first item (forward loading indicator)
// User navigate to the last item (forward loading indicator)
updatedState.eventSink(
MediaViewerEvents.OnNavigateTo(0)
MediaViewerEvents.OnNavigateTo(2)
)
// data source claims that there is no more items to load forward
mediaGalleryDataSource.emitGroupedMediaItems(
@ -614,19 +614,21 @@ class MediaViewerPresenterTest {
if (mode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) {
GroupedMediaItems(
imageAndVideoItems = persistentListOf(),
fileItems = persistentListOf(anImage, aBackwardLoadingIndicator),
fileItems = persistentListOf(aBackwardLoadingIndicator, anImage),
)
} else {
GroupedMediaItems(
imageAndVideoItems = persistentListOf(anImage, aBackwardLoadingIndicator),
imageAndVideoItems = persistentListOf(aBackwardLoadingIndicator, anImage),
fileItems = persistentListOf(),
)
}
)
)
skipItems(1)
val stateWithSnackbar = awaitItem()
assertThat(stateWithSnackbar.snackbarMessage!!.messageResId).isEqualTo(expectedSnackbarResId)
var stateWithSnackbar = awaitItem()
while (stateWithSnackbar.snackbarMessage == null) {
stateWithSnackbar = awaitItem()
}
assertThat(stateWithSnackbar.snackbarMessage.messageResId).isEqualTo(expectedSnackbarResId)
}
}
@ -665,41 +667,42 @@ class MediaViewerPresenterTest {
if (mode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) {
GroupedMediaItems(
imageAndVideoItems = persistentListOf(),
fileItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator),
fileItems = persistentListOf(aBackwardLoadingIndicator, anImage, aForwardLoadingIndicator),
)
} else {
GroupedMediaItems(
imageAndVideoItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator),
imageAndVideoItems = persistentListOf(aBackwardLoadingIndicator, anImage, aForwardLoadingIndicator),
fileItems = persistentListOf(),
)
}
)
)
val updatedState = awaitItem()
// User navigate to the last item (backward loading indicator)
// User navigate to the first item (backward loading indicator)
updatedState.eventSink(
MediaViewerEvents.OnNavigateTo(2)
MediaViewerEvents.OnNavigateTo(0)
)
skipItems(1)
// data source claims that there is no more items to load backward
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
if (mode is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios) {
GroupedMediaItems(
imageAndVideoItems = persistentListOf(),
fileItems = persistentListOf(aForwardLoadingIndicator, anImage),
fileItems = persistentListOf(anImage, aForwardLoadingIndicator),
)
} else {
GroupedMediaItems(
imageAndVideoItems = persistentListOf(aForwardLoadingIndicator, anImage),
imageAndVideoItems = persistentListOf(anImage, aForwardLoadingIndicator),
fileItems = persistentListOf(),
)
}
)
)
skipItems(1)
val stateWithSnackbar = awaitItem()
assertThat(stateWithSnackbar.snackbarMessage!!.messageResId).isEqualTo(expectedSnackbarResId)
var stateWithSnackbar = awaitItem()
while (stateWithSnackbar.snackbarMessage == null) {
stateWithSnackbar = awaitItem()
}
assertThat(stateWithSnackbar.snackbarMessage.messageResId).isEqualTo(expectedSnackbarResId)
}
}
@ -717,7 +720,7 @@ class MediaViewerPresenterTest {
mediaGalleryDataSource.emitGroupedMediaItems(
AsyncData.Success(
GroupedMediaItems(
imageAndVideoItems = persistentListOf(aForwardLoadingIndicator, anImage, aBackwardLoadingIndicator),
imageAndVideoItems = persistentListOf(aBackwardLoadingIndicator, anImage, aForwardLoadingIndicator),
fileItems = persistentListOf(),
)
)

View file

@ -76,7 +76,6 @@ class DefaultSyncPendingNotificationsRequestBuilder(
.removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
// If we're in an air-gapped environment, we shouldn't validate internet connectivity, as the checker will fail and the worker won't run at all.
// Note this will always be false for FOSS, since the feature is only enabled in Element Pro.
if (networkMonitor.isInAirGappedEnvironment.first()) {
Timber.d("In an air-gapped environment, not adding NET_CAPABILITY_VALIDATED to the network request")
networkRequestBuilder.removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)

View file

@ -80,6 +80,9 @@ class DefaultSyncPendingNotificationsRequestBuilderTest {
sessionId = A_SESSION_ID,
sdkVersion = 33,
isInAirGapEnvironment = false,
featureFlagService = FakeFeatureFlagService(initialState = mapOf(
FeatureFlags.ValidateNetworkWhenSchedulingNotificationFetching.key to true
)),
)
val results = request.build()
@ -100,6 +103,9 @@ class DefaultSyncPendingNotificationsRequestBuilderTest {
sessionId = A_SESSION_ID,
sdkVersion = 33,
isInAirGapEnvironment = true,
featureFlagService = FakeFeatureFlagService(initialState = mapOf(
FeatureFlags.ValidateNetworkWhenSchedulingNotificationFetching.key to true
)),
)
val results = request.build()

View file

@ -0,0 +1,9 @@
This module is a wrapper for the Android code distributed in the rustls-platform-verifier-android crate.
To avoid the distribution mess that this library has (download a Rust crate, then search for it using Gradle and use it as local maven repo),
we previously just manually updated the AAR file instead using a script. This won't work for F-Droid because the AAR library is a black box with
no sources attached to it, so we can't use it like that.
Instead, for the time being, we're adding the single `CertificateVerifier.kt` class this AAR had in it as part of our sources.
When this file is updated, the [UPDATED.md](./UPDATED.md) file should be updated too with the commit SHA of the new version.

View file

@ -0,0 +1,7 @@
Below is the commit SHA in [rustls-platform-verifier](https://github.com/rustls/rustls-platform-verifier) library used to update the code in this module:
```
996b1c903491641b17b3c9afb65d1352f6fc6b76
```
Please update it after making manual changes.

View file

@ -0,0 +1,24 @@
import extension.buildConfigFieldBoolean
/*
* 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.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "org.rustls.platformverifier"
buildFeatures {
buildConfig = true
}
defaultConfig {
buildConfigFieldBoolean("TEST", false)
}
}

View file

@ -0,0 +1,480 @@
@file:SuppressLint("LogNotTimber", "ObsoleteSdkInt")
@file:Suppress("KotlinConstantConditions")
// IMPORTANT: this file comes from rustls-platform-verifier and should not be modified locally.
/*
* Copyright (c) 2022 1Password
*
* SPDX-License-Identifier: MIT
*/
package org.rustls.platformverifier
import android.annotation.SuppressLint
import android.content.Context
import android.net.http.X509TrustManagerExtensions
import android.os.Build
import android.util.Log
import java.io.ByteArrayInputStream
import java.io.File
import java.security.KeyStore
import java.security.KeyStoreException
import java.security.MessageDigest
import java.security.PublicKey
import java.security.cert.CertPathValidator
import java.security.cert.CertPathValidatorException
import java.security.cert.CertificateException
import java.security.cert.CertificateExpiredException
import java.security.cert.CertificateFactory
import java.security.cert.CertificateNotYetValidException
import java.security.cert.CertificateParsingException
import java.security.cert.PKIXBuilderParameters
import java.security.cert.PKIXRevocationChecker
import java.security.cert.X509Certificate
import java.util.Date
import java.util.EnumSet
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
import javax.security.auth.x500.X500Principal
// If this is updated, update the Rust definition too.
// Marked private as this is not meant to be used in Android code.
private enum class StatusCode(val value: Int) {
Ok(0),
Unavailable(1),
Expired(2),
UnknownCert(3),
Revoked(4),
InvalidEncoding(5),
InvalidExtension(6),
}
// Marked private as this is not meant to be used in Android code.
private class VerificationResult(
status: StatusCode,
@Suppress("unused") val message: String? = null
) {
@Suppress("unused")
private val code: Int = status.value
}
// NOTE: All TrustManager and certificate validation methods are not thread safe. These
// are all guarded by Kotlin's `Synchronized` accessors to prevent undefined behavior.
// Only JNI and test code calls this, so unused code warnings are suppressed.
// Internal for test code - no other Kotlin code should use this object directly.
@Suppress("unused")
// We want to show a difference between Kotlin-side logs and those in Rust code
@SuppressLint("LongLogTag")
internal object CertificateVerifier {
private const val TAG = "rustls-platform-verifier-android"
private fun createTrustManager(keystore: KeyStore?): X509TrustManagerExtensions? {
// This can never throw since the default algorithm is used.
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(keystore)
val availableTrustManagers = try {
factory.trustManagers
} catch (e: RuntimeException) {
Log.w(TAG, "exception thrown creating a TrustManager: $e")
return null
}
for (manager in availableTrustManagers) {
if (manager is X509TrustManager) {
// Kotlin ensures this can't throw at runtime since it knows that
// it must be the correct type by now.
return X509TrustManagerExtensions(manager)
}
}
Log.e(TAG, "failed to find a usable trust manager")
return null
}
private fun makeLazyTrustManager(keystore: KeyStore?): Lazy<X509TrustManagerExtensions?> {
// Ensure the keystore is loaded. Since all of the trust managers are initialized in a
// `Lazy`, this will only run once.
keystore?.load(null)
return lazy { createTrustManager(keystore) }
}
// -- Test only --
// Ideally, all of this will be optimized out at compile time due to not being accessed
// in release builds.
@get:Synchronized
private val mockKeystore: KeyStore = KeyStore.getInstance(KeyStore.getDefaultType())
@get:Synchronized
private var mockTrustManager: Lazy<X509TrustManagerExtensions?> =
makeLazyTrustManager(mockKeystore)
@JvmStatic
private fun addMockRoot(root: ByteArray) {
if (!BuildConfig.TEST) {
throw Exception("attempted to add a mock root outside a test!")
}
val alias = "root_${mockKeystore.size()}"
// Throwing here is fine since test roots should always be well-formed
val cert = certFactory.generateCertificate(ByteArrayInputStream(root))
mockKeystore.setCertificateEntry(alias, cert)
reloadMockData()
}
@JvmStatic
private fun clearMockRoots() {
// Reload to get a completely fresh internal state
mockKeystore.load(null)
reloadMockData()
}
@JvmStatic
private fun reloadMockData() {
if (mockTrustManager.isInitialized()) {
mockTrustManager = makeLazyTrustManager(mockKeystore)
}
}
// Get a list of the system's root CAs.
// Function is public for testing only.
@JvmStatic
fun getSystemRootCAs(): List<X509Certificate> {
val rootCAs = mutableListOf<X509Certificate>()
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(systemKeystore)
val availableTrustManagers = try {
factory.trustManagers
} catch (e: RuntimeException) {
Log.w(TAG, "exception thrown creating a TrustManager: $e")
return rootCAs
}
availableTrustManagers.forEach { trustManager ->
if (trustManager is X509TrustManager) {
rootCAs.addAll(trustManager.acceptedIssuers)
}
}
return rootCAs
}
// -- End testing requirements --
private val certFactory: CertificateFactory = CertificateFactory.getInstance("X.509")
private var systemTrustAnchorCache = hashSetOf<Pair<X500Principal, PublicKey>>()
@get:Synchronized
private var systemCertificateDirectory: File? = System.getenv("ANDROID_ROOT")?.let { rootPath ->
File("$rootPath/etc/security/cacerts")
}
@get:Synchronized
private val systemKeystore: KeyStore? = try {
KeyStore.getInstance("AndroidCAStore")
} catch (_: KeyStoreException) {
null
}
@get:Synchronized
private val systemTrustManager: Lazy<X509TrustManagerExtensions?> =
makeLazyTrustManager(systemKeystore)
@JvmStatic
private fun verifyCertificateChain(
@Suppress("UNUSED_PARAMETER") context: Context,
serverName: String,
authMethod: String,
allowedEkus: Array<String>,
ocspResponse: ByteArray?,
time: Long,
certChain: Array<ByteArray>
): VerificationResult {
// Convert the array of (supposedly) DER bytes into certificates.
val certificateChain = mutableListOf<X509Certificate>()
certChain.forEach { certBytes ->
val certificate = try {
certFactory.generateCertificate(ByteArrayInputStream(certBytes))
} catch (e: CertificateException) {
return VerificationResult(StatusCode.InvalidEncoding)
}
certificateChain.add(certificate as X509Certificate)
}
// Will never throw `ArrayIndexOutOfBoundsException` because `rustls`'s `ServerCertVerifier` trait
// has a mandatory `end_entity` parameter in `verify_server_cert`.
val endEntity = certificateChain[0]
// Check that the certificate is valid at the point of time provided by `rustls`.
try {
endEntity.checkValidity(Date(time))
} catch (e: CertificateExpiredException) {
return VerificationResult(StatusCode.Expired)
} catch (e: CertificateNotYetValidException) {
return VerificationResult(StatusCode.Expired)
}
// Check that this certificate can be used in a TLS server.
if (!verifyCertUsage(endEntity, allowedEkus)) {
return VerificationResult(StatusCode.InvalidExtension)
}
// Select the trust manager to use.
//
// We select them as follows:
// - If built for release, only use the system trust manager. This should let all test-related
// code be optimized out.
// - If built for tests:
// - If the mock CA store has any values, use the mock trust manager.
// - Otherwise, use the system trust manager.
val (trustManager, keystore) = if (!BuildConfig.TEST) {
val trustManager =
systemTrustManager.value ?: return VerificationResult(StatusCode.Unavailable)
Pair(trustManager, systemKeystore)
} else {
if (mockKeystore.size() != 0) {
val trustManager = mockTrustManager.value!!
Pair(trustManager, mockKeystore)
} else {
val trustManager =
systemTrustManager.value ?: return VerificationResult(StatusCode.Unavailable)
Pair(trustManager, systemKeystore)
}
}
// Verify that the certificate chain is valid and correct, and nothing more.
//
// NOTE: This does not validate `serverName` is valid for the end-entity certificate.
// That is handled in Rust as Android/Java do not currently provide a RFC 6125 compliant
// hostname verifier. Additionally, even the RFC 2818 verifier is not available until API 24.
//
// `serverName` is only used for pinning/CT requirements.
//
// Returns the "the properly ordered chain used for verification as a list of X509Certificates.",
// meaning a list from end-entity certificate to trust-anchor.
val validChain = try {
trustManager.checkServerTrusted(certificateChain.toTypedArray(), authMethod, serverName)
} catch (e: CertificateException) {
// In test configurations we may see `checkServerTrusted` fail once vendored test
// certificates pass their expiry date. We try to avoid that by using a fixed
// verification time when calling `endEntity.checkValidity` above, however we can't
// fix the time for the `checkServerTrusted` call.
//
// To make diagnosing CI test failures easier we try to find the root cause of
// checkServerTrusted failing, returning a different `StatusCode` as appropriate.
if (BuildConfig.TEST) {
var rootCause: Throwable? = e
while (rootCause?.cause != null && rootCause.cause != rootCause) {
rootCause = rootCause.cause
}
return when (rootCause) {
is CertificateExpiredException, is CertificateNotYetValidException -> VerificationResult(
StatusCode.Expired,
rootCause.toString()
)
else -> VerificationResult(StatusCode.UnknownCert, rootCause.toString())
}
}
// In non-test configurations we should have caught expiry errors earlier and
// can simply return an unknown cert error without digging through the exception
// cause chain.
return VerificationResult(StatusCode.UnknownCert, e.toString())
}
// TEST ONLY: Mock test suite cannot attempt to check revocation status if no OSCP data has been stapled,
// because Android requires certificates to an specify OCSP responder for network fetch in this case.
// If in testing w/o OCSP stapled, short-circuit here - only prior checks apply.
if (BuildConfig.TEST && (mockKeystore.size() != 0) && (ocspResponse == null)) {
return VerificationResult(StatusCode.Ok)
}
// Try to check the revocation status of the cert, if it is supported.
//
// This is supported at >= API 24, but we're supporting 22 (Android 5) for the best
// compatibility.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
// Note:
//
// 1. Android does not provide any way only to attempt to validate revocation from cached
// data like the other platforms do. This means it will always use the network for
// certificates which had no stapled response.
//
// 2: Likely because of 1, Android requires all issued certificates to have some form of
// revocation included in their authority information. This doesn't work universally as
// issuing certificates in use may omit authority access information (for example the
// Let's Encrypt R3 Intermediate Certificate).
//
// Given these constraints, the best option is to only check revocation information
// at the end-entity depth. We will prefer OCSP (to use stapled information if possible).
// If there is no stapled OCSP response, Android may use the network to attempt to fetch
// one. If OCSP checking fails, it may fall back to fetching CRLs. We allow "soft"
// failures, for example transient network errors.
//
// In the case of a non-public root, such as an internal CA or self-signed certificate,
// we opt to skip revocation checks entirely. The only exception is if the server
// provided stapled OCSP data, which is an explicit signal and won't introduce non-ideal
// platform behavior when attempting validation.
//
// This is because these are cases where a user or administrator has explicitly opted to
// trust a certificate they (at least believe) have control over. These certificates rarely
// contain revocation information as well, so these cases don't lose much.
// See https://github.com/rustls/rustls-platform-verifier/issues/69 as well.
if (ocspResponse == null && !isKnownRoot(validChain.last())) {
// Chain validation must have succeeded by this point.
return VerificationResult(StatusCode.Ok)
}
val parameters = PKIXBuilderParameters(keystore, null)
val validator = CertPathValidator.getInstance("PKIX")
val revocationChecker = validator.revocationChecker as PKIXRevocationChecker
revocationChecker.options = EnumSet.of(
PKIXRevocationChecker.Option.SOFT_FAIL,
PKIXRevocationChecker.Option.ONLY_END_ENTITY
)
// Use the OCSP data `rustls` provided, if present.
// Its expected that the server only sends revocation data for its own leaf certificate.
//
// If this field is set, then Android will use it and skip any networking to
// attempt a fetch for that certificate. Otherwise, it will attempt to fetch it from the network.
// Ref: https://cs.android.com/android/platform/superproject/+/master:libcore/ojluni/src/main/java/sun/security/provider/certpath/RevocationChecker.java;l=694
ocspResponse?.let { providedResponse ->
revocationChecker.ocspResponses = mapOf(endEntity to providedResponse)
}
// Use the custom revocation definition.
// "Note that when a `PKIXRevocationChecker` is added to `PKIXParameters`, it clones the `PKIXRevocationChecker`;
// thus any subsequent modifications to the `PKIXRevocationChecker` have no effect."
// - https://developer.android.com/reference/java/security/cert/PKIXRevocationChecker
parameters.certPathCheckers = listOf(revocationChecker)
// "When supplying a revocation checker in this manner, it will be used to check revocation
// irrespective of the setting of the `RevocationEnabled` flag."
// - https://developer.android.com/reference/java/security/cert/PKIXRevocationChecker
parameters.isRevocationEnabled = false
// Validate the revocation status of the end entity certificate.
try {
validator.validate(certFactory.generateCertPath(validChain), parameters)
} catch (e: CertPathValidatorException) {
// LetsEncrypt no longer include OCSP information (as OCSP is being deprecated) which Android is not
// happy with since it *only* tries OCSP by default. We aren't 100% decided on how to fix this yet for real
// (see https://github.com/rustls/rustls-platform-verifier/pull/179) so for now we implement an out for
// tests to allow regular maintenance to proceed.
if (BuildConfig.TEST && e.reason == CertPathValidatorException.BasicReason.UNSPECIFIED) {
return VerificationResult(StatusCode.Ok)
}
return VerificationResult(StatusCode.Revoked, e.toString())
}
} else {
// This is allowed to be skipped since revocation checking is best-effort.
Log.w(TAG, "did not attempt to validate OCSP due to Android version")
}
return VerificationResult(StatusCode.Ok)
}
private fun verifyCertUsage(certificate: X509Certificate, allowedEkus: Array<String>): Boolean {
val ekus = try {
certificate.extendedKeyUsage
}
// This should be unreachable, but could happen.
catch (_: CertificateParsingException) {
return false
} catch (_: NullPointerException) {
// According to Chromium's implementation, this can crash when the EKU data is malformed.
Log.w(TAG, "exception handling certificate EKU")
return false
} ?: return true // If the list is empty, we have nothing to do.
return ekus.any { allowedEkus.contains(it) }
}
// Android hashes a principal using the first four bytes of its MD5 digest, encoded in
// lowercase hex and reversed.
//
// Ref: https://source.chromium.org/chromium/chromium/src/+/main:net/android/java/src/org/chromium/net/X509Util.java;l=339
private fun hashPrincipal(principal: X500Principal): String {
val hexDigits = "0123456789abcdef".toCharArray()
val digest = MessageDigest.getInstance("MD5").digest(principal.encoded)
val hexChars = CharArray(8)
for (i in 0..3) {
// Kotlin doesn't support bitwise operators for bytes, only Int and Long.
val digestByte = digest[3 - i].toInt()
hexChars[2 * i] = hexDigits[(digestByte shr 4) and 0xf]
hexChars[2 * i + 1] = hexDigits[digestByte and 0xf]
}
return String(hexChars)
}
// Check if CA root is known or not.
// Known means installed in root CA store, either a preset public CA or a custom one installed by an enterprise/user.
//
// Ref: https://source.chromium.org/chromium/chromium/src/+/main:net/android/java/src/org/chromium/net/X509Util.java;l=351
fun isKnownRoot(root: X509Certificate): Boolean {
// System keystore and cert directory must be non-null to perform checking
systemKeystore?.let { loadedSystemKeystore ->
systemCertificateDirectory?.let { loadedSystemCertificateDirectory ->
// Check the in-memory cache first
val key = Pair(root.subjectX500Principal, root.publicKey)
if (systemTrustAnchorCache.contains(key)) {
return true
}
// System trust anchors are stored under a hash of the principal.
// In case of collisions, append number.
val hash = hashPrincipal(root.subjectX500Principal)
var i = 0
while (true) {
val alias = "$hash.$i"
if (!File(loadedSystemCertificateDirectory, alias).exists()) {
break
}
val anchor = loadedSystemKeystore.getCertificate("system:$alias")
// It's possible for `anchor` to be `null` if the user deleted a trust anchor.
// Continue iterating as there may be further collisions after the deleted anchor.
if (anchor == null) {
continue
// This should never happen
} else if (anchor !is X509Certificate) {
// SAFETY: This logs a unique identifier (hash value) only in cases where a file within the
// system's root trust store is not a valid X509 certificate (extremely unlikely error).
// The hash doesn't tell us any sensitive information about the invalid cert or reveal any of
// its contents - it just lets us ID the bad file if a user is having TLS failure issues.
Log.e(TAG, "anchor is not a certificate, alias: $alias")
continue
// If subject and public key match, it's a system root.
} else {
if ((root.subjectX500Principal == anchor.subjectX500Principal) && (root.publicKey == anchor.publicKey)) {
systemTrustAnchorCache.add(key)
return true
}
}
i += 1
}
}
}
// Not found in cache or store: non-public
return false
}
}

View file

@ -51,6 +51,7 @@ sealed interface SlashCommand {
data class ChangeDisplayName(val displayName: String) : SlashCommandAdmin
data class ChangeDisplayNameForRoom(val displayName: String) : SlashCommandAdmin
data class ChangeRoomAvatar(val url: String) : SlashCommandAdmin
data class ChangeAvatar(val url: String) : SlashCommandAdmin
data class ChangeAvatarForRoom(val url: String) : SlashCommandAdmin
data class SendSpoiler(val message: String) : SlashCommandSendMessage
data class SendWithPrefix(val prefix: MessagePrefix, val message: CharSequence) : SlashCommandSendMessage

View file

@ -120,6 +120,15 @@ enum class Command(
isDevCommand = true,
isSupported = false,
),
CHANGE_AVATAR(
command = "/myavatar",
parameters = "<mxc_url>",
description = R.string.slash_command_description_avatar,
isAllowedInThread = false,
// Dev command since user has to know the mxc url
isDevCommand = true,
isSupported = false,
),
CHANGE_AVATAR_FOR_ROOM(
command = "/myroomavatar",
parameters = "<mxc_url>",

View file

@ -44,6 +44,7 @@ class CommandExecutor(
): Result<Unit> {
return when (slashCommand) {
is SlashCommand.BanUser -> banUser(slashCommand)
is SlashCommand.ChangeAvatar -> changeAvatar()
is SlashCommand.ChangeAvatarForRoom -> changeAvatarForRoom()
is SlashCommand.ChangeDisplayName -> changeDisplayName(slashCommand)
is SlashCommand.ChangeDisplayNameForRoom -> changeDisplayNameForRoom()
@ -178,6 +179,10 @@ class CommandExecutor(
return matrixClient.setDisplayName(slashCommand.displayName)
}
private fun changeAvatar(): Result<Unit> {
return Result.failure(Exception("Not yet implemented"))
}
private fun changeAvatarForRoom(): Result<Unit> {
return Result.failure(Exception("Not yet implemented"))
}

View file

@ -107,6 +107,18 @@ class CommandParser(
syntaxError(Command.ROOM_AVATAR)
}
}
Command.CHANGE_AVATAR.matches(slashCommand) -> {
if (messageParts.size == 2) {
val url = messageParts[1]
if (url.isMxcUrl()) {
SlashCommand.ChangeAvatar(url)
} else {
syntaxError(Command.CHANGE_AVATAR)
}
} else {
syntaxError(Command.CHANGE_AVATAR)
}
}
Command.CHANGE_AVATAR_FOR_ROOM.matches(slashCommand) -> {
if (messageParts.size == 2) {
val url = messageParts[1]

View file

@ -11,6 +11,7 @@ import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.slashcommands.api.SlashCommand
@ -18,6 +19,8 @@ import io.element.android.libraries.slashcommands.api.SlashCommandService
import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion
import io.element.android.services.toolbox.api.strings.StringProvider
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.Duration.Companion.seconds
@ContributesBinding(RoomScope::class)
class DefaultSlashCommandService(
@ -26,6 +29,7 @@ class DefaultSlashCommandService(
private val stringProvider: StringProvider,
private val appPreferencesStore: AppPreferencesStore,
private val featureFlagService: FeatureFlagService,
private val capabilitiesProvider: HomeserverCapabilitiesProvider,
) : SlashCommandService {
override suspend fun getSuggestions(
text: String,
@ -33,19 +37,41 @@ class DefaultSlashCommandService(
): List<SlashCommandSuggestion> {
if (!featureFlagService.isFeatureEnabled(FeatureFlags.SlashCommand)) return emptyList()
val isDeveloperModeEnabled = appPreferencesStore.isDeveloperModeEnabledFlow().first()
return Command.entries.filter {
it.startsWith(text)
}.filter {
!isInThread || it.isAllowedInThread
}.filter {
!it.isDevCommand || isDeveloperModeEnabled
}.map {
SlashCommandSuggestion(
command = it.command,
parameters = it.parameters,
description = stringProvider.getString(it.description),
)
}
return Command.entries
.asSequence()
.filter { it.startsWith(text) }
.filter { !isInThread || it.isAllowedInThread }
.filter { !it.isDevCommand || isDeveloperModeEnabled }
// Don't include the change display name commands if the user can't change their display name
.run {
val canUserChangeDisplayName = withTimeoutOrNull(5.seconds) {
capabilitiesProvider.canChangeDisplayName().getOrNull()
} ?: false
if (!canUserChangeDisplayName) {
filterNot { it == Command.CHANGE_DISPLAY_NAME || it == Command.CHANGE_DISPLAY_NAME_FOR_ROOM }
} else {
this
}
}
// Don't include the change avatar commands if the user can't change their avatar url
.run {
val canUserChangeAvatar = withTimeoutOrNull(5.seconds) {
capabilitiesProvider.canChangeAvatarUrl().getOrNull()
} ?: false
if (!canUserChangeAvatar) {
filterNot { it == Command.CHANGE_AVATAR || it == Command.CHANGE_AVATAR_FOR_ROOM }
} else {
this
}
}
.map {
SlashCommandSuggestion(
command = it.command,
parameters = it.parameters,
description = stringProvider.getString(it.description),
)
}
.toList()
}
override suspend fun parse(

View file

@ -26,6 +26,7 @@
<string name="slash_command_description_topic">Set the room topic</string>
<string name="slash_command_description_remove_user">Removes user with given id from this room</string>
<string name="slash_command_description_nick">Changes your display nickname</string>
<string name="slash_command_description_avatar">Changes your profile picture in all rooms</string>
<string name="slash_command_confetti">Sends the given message with confetti</string>
<string name="slash_command_snow">Sends the given message with snowfall</string>
<string name="slash_command_description_plain">Sends a message as plain text, without interpreting it as markdown</string>

View file

@ -78,12 +78,19 @@ class CommandParserTest {
}
@Test
fun parseSlashCommandPlainAndNick() = runTest {
fun parseSlashCommandPlain() = runTest {
test("/plain hello", SlashCommand.SendPlainText("hello"))
test("/plain", SlashCommand.ErrorSyntax("A string/plain, /plain <message>"))
}
@Test
fun parseSlashCommandNickAndMyAvatar() = runTest {
test("/nick John", SlashCommand.ChangeDisplayName("John"))
test("/nick", SlashCommand.ErrorSyntax("A string/nick, /nick <display-name>"))
test("/myavatar mxc://matrix.org/abc", SlashCommand.ChangeAvatar("mxc://matrix.org/abc"))
test("/myavatar http://notmxc", SlashCommand.ErrorSyntax("A string/myavatar, /myavatar <mxc_url>"))
test("/myavatar", SlashCommand.ErrorSyntax("A string/myavatar, /myavatar <mxc_url>"))
}
@Test

View file

@ -13,6 +13,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.timeline.MsgType
import io.element.android.libraries.matrix.test.FakeHomeserverCapabilitiesProvider
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
@ -116,6 +117,26 @@ class DefaultSlashCommandServiceTest {
sendMessage.assertions().isCalledOnce()
}
@Test
fun `canChangeDisplayName is respected in suggestions`() = runTest {
var result = false
val capabilitiesProvider = FakeHomeserverCapabilitiesProvider(
canChangeDisplayName = { Result.success(result) },
)
val sut = createDefaultSlashCommandService(capabilitiesProvider = capabilitiesProvider)
// Initially, with a disabled capability, the change display name command should not be in the suggestions
var changeNameCommand = sut.getSuggestions("", isInThread = false)
.find { it.command == Command.CHANGE_DISPLAY_NAME.command }
assertThat(changeNameCommand).isNull()
// When the capability is true, the command should be included in the suggestions
result = true
changeNameCommand = sut.getSuggestions("", isInThread = false)
.find { it.command == Command.CHANGE_DISPLAY_NAME.command }
assertThat(changeNameCommand).isNotNull()
}
@Test
fun `proceedAdmin delegates to commandExecutor`() = runTest {
val leaveRoomLambda = lambdaRecorder<Result<Unit>> {
@ -155,11 +176,13 @@ class DefaultSlashCommandServiceTest {
commandExecutor: CommandExecutor = createCommandExecutor(
stringProvider = stringProvider,
),
capabilitiesProvider: FakeHomeserverCapabilitiesProvider = FakeHomeserverCapabilitiesProvider(),
) = DefaultSlashCommandService(
commandParser = commandParser,
commandExecutor = commandExecutor,
stringProvider = stringProvider,
appPreferencesStore = appPreferencesStore,
featureFlagService = featureFlagService,
capabilitiesProvider = capabilitiesProvider,
)
}

View file

@ -400,6 +400,7 @@ fun TextComposer(
onAddAttachment = onAddAttachment,
onDeleteVoiceMessage = onDeleteVoiceMessage,
onVoiceRecorderEvent = onVoiceRecorderEvent,
onResetComposerMode = onResetComposerMode,
)
}
@ -409,6 +410,15 @@ fun TextComposer(
SoftKeyboardEffect(showTextFormatting, onRequestFocus) { it }
// Re-focus the text input when voice recording ends so the user can continue typing
var previousVoiceMessageState by remember { mutableStateOf(voiceMessageState) }
LaunchedEffect(voiceMessageState) {
if (voiceMessageState is VoiceMessageState.Idle && previousVoiceMessageState !is VoiceMessageState.Idle) {
onRequestFocus()
}
previousVoiceMessageState = voiceMessageState
}
val latestOnReceiveSuggestion by rememberUpdatedState(onReceiveSuggestion)
if (state is TextEditorState.Rich) {
val menuAction = state.richTextEditorState.menuAction
@ -440,6 +450,7 @@ private fun StandardLayout(
onAddAttachment: () -> Unit,
onDeleteVoiceMessage: () -> Unit,
onVoiceRecorderEvent: (VoiceMessageRecorderEvent) -> Unit,
onResetComposerMode: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
@ -506,6 +517,14 @@ private fun StandardLayout(
) {
if (voiceMessageState is VoiceMessageState.Idle) {
textInput()
} else if (composerMode is MessageComposerMode.Special) {
TextInputBox(
composerMode = composerMode,
onResetComposerMode = onResetComposerMode,
isTextEmpty = true,
) {
voiceRecording()
}
} else {
voiceRecording()
}