Merge branch 'develop' into feature/fga/live_location_rendering
This commit is contained in:
commit
22f0b2e449
334 changed files with 8886 additions and 2214 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -223,6 +223,8 @@ interface MatrixClient {
|
|||
* Resets the cached client `well-known` config by the SDK.
|
||||
*/
|
||||
suspend fun resetWellKnownConfig(): Result<Unit>
|
||||
|
||||
fun homeserverCapabilities(): HomeserverCapabilitiesProvider
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -1 +0,0 @@
|
|||
Updated rustls-platform-verifier-android.aar using `rustls-platform-verifier-0.1.1.aar`
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
9
libraries/rustls-tls/README.md
Normal file
9
libraries/rustls-tls/README.md
Normal 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.
|
||||
7
libraries/rustls-tls/UPDATED.md
Normal file
7
libraries/rustls-tls/UPDATED.md
Normal 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.
|
||||
24
libraries/rustls-tls/build.gradle.kts
Normal file
24
libraries/rustls-tls/build.gradle.kts
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>",
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue