First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2024 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.
*/
plugins {
id("io.element.android-library")
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "io.element.android.libraries.matrix.impl"
}
setupDependencyInjection()
dependencies {
releaseImplementation(libs.matrix.sdk)
if (file("${rootDir.path}/libraries/rustsdk/matrix-rust-sdk.aar").exists()) {
println("\nNote: Using local binary of the Rust SDK.\n")
debugImplementation(projects.libraries.rustsdk)
} else {
debugImplementation(libs.matrix.sdk)
}
implementation(projects.appconfig)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.di)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.network)
implementation(projects.libraries.preferences.api)
implementation(projects.services.analytics.api)
implementation(projects.services.toolbox.api)
api(projects.libraries.matrix.api)
implementation(projects.libraries.core)
implementation("net.java.dev.jna:jna:5.18.1@aar")
implementation(libs.androidx.datastore.preferences)
implementation(libs.serialization.json)
implementation(libs.kotlinx.collections.immutable)
testCommonDependencies(libs)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.previewutils)
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.services.toolbox.test)
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2025 Element Creations Ltd.
~ Copyright 2022 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.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import org.matrix.rustcomponents.sdk.ClientBuilder
interface ClientBuilderProvider {
fun provide(): ClientBuilder
}
@ContributesBinding(AppScope::class)
class RustClientBuilderProvider : ClientBuilderProvider {
override fun provide(): ClientBuilder {
return ClientBuilder()
}
}
@@ -0,0 +1,127 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.matrix.impl.mapper.toSessionData
import io.element.android.libraries.matrix.impl.paths.getSessionPaths
import io.element.android.libraries.matrix.impl.util.anonymizedTokens
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.matrix.rustcomponents.sdk.ClientDelegate
import org.matrix.rustcomponents.sdk.ClientSessionDelegate
import org.matrix.rustcomponents.sdk.Session
import timber.log.Timber
import java.lang.ref.WeakReference
import java.util.concurrent.atomic.AtomicBoolean
private val loggerTag = LoggerTag("RustClientSessionDelegate")
/**
* This class is responsible for handling the session data for the Rust SDK.
*
* It implements both [ClientSessionDelegate] and [ClientDelegate] to react to session data updates and auth errors.
*
* IMPORTANT: you must set the [client] property as soon as possible so [didReceiveAuthError] can work properly.
*/
class RustClientSessionDelegate(
private val sessionStore: SessionStore,
private val appCoroutineScope: CoroutineScope,
coroutineDispatchers: CoroutineDispatchers,
) : ClientSessionDelegate, ClientDelegate {
// Used to ensure several calls to `didReceiveAuthError` don't trigger multiple logouts
private val isLoggingOut = AtomicBoolean(false)
// To make sure only one coroutine affecting the token persistence can run at a time
private val updateTokensDispatcher = coroutineDispatchers.io.limitedParallelism(1)
// This Client needs to be set up as soon as possible so `didReceiveAuthError` can work properly.
private var client: WeakReference<RustMatrixClient> = WeakReference(null)
/**
* Sets the [ClientDelegate] for the [RustMatrixClient], and keeps a reference to the client so it can be used later.
*/
fun bindClient(client: RustMatrixClient) {
this.client = WeakReference(client)
}
/**
* Clears the current client reference.
*/
fun clearCurrentClient() {
this.client.clear()
}
override fun saveSessionInKeychain(session: Session) {
appCoroutineScope.launch(updateTokensDispatcher) {
val existingData = sessionStore.getSession(session.userId) ?: return@launch
val (anonymizedAccessToken, anonymizedRefreshToken) = session.anonymizedTokens()
Timber.tag(loggerTag.value).d(
"Saving new session data with token: access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'. " +
"Was token valid: ${existingData.isTokenValid}"
)
val newData = session.toSessionData(
isTokenValid = true,
loginType = existingData.loginType,
passphrase = existingData.passphrase,
sessionPaths = existingData.getSessionPaths(),
)
sessionStore.updateData(newData)
Timber.tag(loggerTag.value).d("Saved new session data with access token: '$anonymizedAccessToken'.")
}.invokeOnCompletion {
if (it != null) {
Timber.tag(loggerTag.value).e(it, "Failed to save new session data.")
}
}
}
override fun didReceiveAuthError(isSoftLogout: Boolean) {
Timber.tag(loggerTag.value).w("didReceiveAuthError(isSoftLogout=$isSoftLogout)")
if (isLoggingOut.getAndSet(true).not()) {
Timber.tag(loggerTag.value).v("didReceiveAuthError -> do the cleanup")
// TODO handle isSoftLogout parameter.
appCoroutineScope.launch(updateTokensDispatcher) {
val currentClient = client.get()
if (currentClient == null) {
Timber.tag(loggerTag.value).w("didReceiveAuthError -> no client, exiting")
isLoggingOut.set(false)
return@launch
}
val existingData = sessionStore.getSession(currentClient.sessionId.value)
val (anonymizedAccessToken, anonymizedRefreshToken) = existingData.anonymizedTokens()
Timber.tag(loggerTag.value).d(
"Removing session data with access token '$anonymizedAccessToken' " +
"and refresh token '$anonymizedRefreshToken'."
)
if (existingData != null) {
// Set isTokenValid to false
val newData = existingData.copy(isTokenValid = false)
sessionStore.updateData(newData)
Timber.tag(loggerTag.value).d("Invalidated session data with access token: '$anonymizedAccessToken'.")
} else {
Timber.tag(loggerTag.value).d("No session data found.")
}
currentClient.logout(userInitiated = false, ignoreSdkError = true)
}.invokeOnCompletion {
if (it != null) {
Timber.tag(loggerTag.value).e(it, "Failed to remove session data.")
}
}
} else {
Timber.tag(loggerTag.value).v("didReceiveAuthError -> already cleaning up")
}
}
override fun retrieveSessionFromKeychain(userId: String): Session {
// This should never be called, as it's only used for multi-process setups
error("retrieveSessionFromKeychain should never be called for Android")
}
}
@@ -0,0 +1,769 @@
/*
* 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.impl
import io.element.android.libraries.androidutils.file.getSizeOfFiles
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScope
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.MatrixClient
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.createroom.RoomPreset
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.NotJoinedRoom
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.spaces.SpaceService
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.impl.encryption.RustEncryptionService
import io.element.android.libraries.matrix.impl.exception.mapClientException
import io.element.android.libraries.matrix.impl.mapper.map
import io.element.android.libraries.matrix.impl.media.RustMediaLoader
import io.element.android.libraries.matrix.impl.media.RustMediaPreviewService
import io.element.android.libraries.matrix.impl.notification.RustNotificationService
import io.element.android.libraries.matrix.impl.notificationsettings.RustNotificationSettingsService
import io.element.android.libraries.matrix.impl.oidc.toRustAction
import io.element.android.libraries.matrix.impl.pushers.RustPushersService
import io.element.android.libraries.matrix.impl.room.GetRoomResult
import io.element.android.libraries.matrix.impl.room.NotJoinedRustRoom
import io.element.android.libraries.matrix.impl.room.RoomContentForwarder
import io.element.android.libraries.matrix.impl.room.RoomInfoMapper
import io.element.android.libraries.matrix.impl.room.RoomSyncSubscriber
import io.element.android.libraries.matrix.impl.room.RustRoomFactory
import io.element.android.libraries.matrix.impl.room.TimelineEventTypeFilterFactory
import io.element.android.libraries.matrix.impl.room.history.map
import io.element.android.libraries.matrix.impl.room.join.map
import io.element.android.libraries.matrix.impl.room.preview.RoomPreviewInfoMapper
import io.element.android.libraries.matrix.impl.roomdirectory.RustRoomDirectoryService
import io.element.android.libraries.matrix.impl.roomdirectory.map
import io.element.android.libraries.matrix.impl.roomlist.RoomListFactory
import io.element.android.libraries.matrix.impl.roomlist.RustRoomListService
import io.element.android.libraries.matrix.impl.roomlist.roomOrNull
import io.element.android.libraries.matrix.impl.spaces.RustSpaceService
import io.element.android.libraries.matrix.impl.sync.RustSyncService
import io.element.android.libraries.matrix.impl.sync.map
import io.element.android.libraries.matrix.impl.usersearch.UserSearchResultMapper
import io.element.android.libraries.matrix.impl.util.SessionPathsProvider
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.matrix.rustcomponents.sdk.AuthData
import org.matrix.rustcomponents.sdk.AuthDataPasswordDetails
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientException
import org.matrix.rustcomponents.sdk.IgnoredUsersListener
import org.matrix.rustcomponents.sdk.Membership
import org.matrix.rustcomponents.sdk.NotificationProcessSetup
import org.matrix.rustcomponents.sdk.PowerLevels
import org.matrix.rustcomponents.sdk.RoomInfoListener
import org.matrix.rustcomponents.sdk.SendQueueRoomErrorListener
import org.matrix.rustcomponents.sdk.TaskHandle
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import java.io.File
import java.util.Optional
import kotlin.jvm.optionals.getOrNull
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import org.matrix.rustcomponents.sdk.CreateRoomParameters as RustCreateRoomParameters
import org.matrix.rustcomponents.sdk.RoomPreset as RustRoomPreset
import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService
class RustMatrixClient(
private val innerClient: Client,
private val sessionStore: SessionStore,
private val sessionDelegate: RustClientSessionDelegate,
private val innerSyncService: ClientSyncService,
appCoroutineScope: CoroutineScope,
dispatchers: CoroutineDispatchers,
baseCacheDirectory: File,
clock: SystemClock,
timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory,
private val featureFlagService: FeatureFlagService,
private val analyticsService: AnalyticsService,
) : MatrixClient {
override val sessionId: UserId = UserId(innerClient.userId())
override val deviceId: DeviceId = DeviceId(innerClient.deviceId())
override val sessionCoroutineScope = appCoroutineScope.childScope(dispatchers.main, "Session-$sessionId")
private val sessionDispatcher = dispatchers.io.limitedParallelism(64)
private val innerRoomListService = innerSyncService.roomListService()
private val innerSpaceService = innerClient.spaceService()
override val roomMembershipObserver = RoomMembershipObserver()
override val syncService = RustSyncService(
inner = innerSyncService,
dispatcher = sessionDispatcher,
sessionCoroutineScope = sessionCoroutineScope
)
override val pushersService = RustPushersService(
client = innerClient,
dispatchers = dispatchers,
)
private val notificationProcessSetup = NotificationProcessSetup.SingleProcess(innerSyncService)
private val innerNotificationClient = runBlocking { innerClient.notificationClient(notificationProcessSetup) }
override val notificationService = RustNotificationService(sessionId, innerNotificationClient, dispatchers, clock)
override val notificationSettingsService = RustNotificationSettingsService(innerClient, sessionCoroutineScope, dispatchers)
override val encryptionService = RustEncryptionService(
client = innerClient,
syncService = syncService,
sessionCoroutineScope = sessionCoroutineScope,
dispatchers = dispatchers,
)
override val roomDirectoryService = RustRoomDirectoryService(
client = innerClient,
sessionDispatcher = sessionDispatcher,
)
private val sessionPathsProvider = SessionPathsProvider(sessionStore)
private val roomSyncSubscriber: RoomSyncSubscriber = RoomSyncSubscriber(innerRoomListService, dispatchers)
override val roomListService: RoomListService = RustRoomListService(
innerRoomListService = innerRoomListService,
sessionCoroutineScope = sessionCoroutineScope,
sessionDispatcher = sessionDispatcher,
roomListFactory = RoomListFactory(
innerRoomListService = innerRoomListService,
sessionCoroutineScope = sessionCoroutineScope,
analyticsService = analyticsService,
),
roomSyncSubscriber = roomSyncSubscriber,
)
override val spaceService: SpaceService = RustSpaceService(
innerSpaceService = innerSpaceService,
roomMembershipObserver = roomMembershipObserver,
sessionCoroutineScope = sessionCoroutineScope,
sessionDispatcher = sessionDispatcher,
)
override val sessionVerificationService = RustSessionVerificationService(
client = innerClient,
isSyncServiceReady = syncService.syncState.map { it == SyncState.Running },
sessionCoroutineScope = sessionCoroutineScope,
)
private val roomInfoMapper = RoomInfoMapper()
private val roomFactory = RustRoomFactory(
roomListService = roomListService,
innerRoomListService = innerRoomListService,
sessionId = sessionId,
deviceId = deviceId,
notificationSettingsService = notificationSettingsService,
sessionCoroutineScope = sessionCoroutineScope,
dispatchers = dispatchers,
systemClock = clock,
roomContentForwarder = RoomContentForwarder(innerRoomListService),
roomSyncSubscriber = roomSyncSubscriber,
timelineEventTypeFilterFactory = timelineEventTypeFilterFactory,
roomMembershipObserver = roomMembershipObserver,
roomInfoMapper = roomInfoMapper,
featureFlagService = featureFlagService,
analyticsService = analyticsService,
)
override val matrixMediaLoader: MatrixMediaLoader = RustMediaLoader(
baseCacheDirectory = baseCacheDirectory,
dispatchers = dispatchers,
innerClient = innerClient,
)
override val mediaPreviewService = RustMediaPreviewService(
sessionCoroutineScope = sessionCoroutineScope,
innerClient = innerClient,
sessionDispatcher = sessionDispatcher,
)
private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate)
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(
MatrixUser(
userId = sessionId,
displayName = null,
avatarUrl = null,
)
)
override val userProfile: StateFlow<MatrixUser> = _userProfile
override val ignoredUsersFlow = mxCallbackFlow<ImmutableList<UserId>> {
// Fetch the initial value manually, the SDK won't return it automatically
channel.trySend(innerClient.ignoredUsers().map(::UserId).toImmutableList())
innerClient.subscribeToIgnoredUsers(object : IgnoredUsersListener {
override fun call(ignoredUserIds: List<String>) {
channel.trySend(ignoredUserIds.map(::UserId).toImmutableList())
}
})
}
.buffer(Channel.UNLIMITED)
.stateIn(sessionCoroutineScope, started = SharingStarted.Eagerly, initialValue = persistentListOf())
init {
// Make sure the session delegate has a reference to the client to be able to logout on auth error
sessionDelegate.bindClient(this)
sessionCoroutineScope.launch {
// Start notification settings
notificationSettingsService.start()
// Update the user profile in the session store if needed
sessionStore.getSession(sessionId.value)?.let { sessionData ->
_userProfile.emit(
MatrixUser(
userId = sessionId,
displayName = sessionData.userDisplayName,
avatarUrl = sessionData.userAvatarUrl,
)
)
}
// Force a refresh of the profile
getUserProfile()
}
}
override fun userIdServerName(): String {
return runCatchingExceptions {
innerClient.userIdServerName()
}
.onFailure {
Timber.w(it, "Failed to get userIdServerName")
}
.getOrNull()
?: sessionId.value.substringAfter(":")
}
override suspend fun getUrl(url: String): Result<ByteArray> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.getUrl(url)
}.mapFailure { it.mapClientException() }
}
override suspend fun getRoom(roomId: RoomId): BaseRoom? = withContext(sessionDispatcher) {
roomFactory.getBaseRoom(roomId)
}
override suspend fun getJoinedRoom(roomId: RoomId): JoinedRoom? = withContext(sessionDispatcher) {
(roomFactory.getJoinedRoomOrPreview(roomId, emptyList()) as? GetRoomResult.Joined)?.joinedRoom
}
/**
* Wait for the room to be available in the client with the correct membership for the current user.
* @param roomId the room id to wait for
* @param timeout the timeout to wait for the room to be available
* @param currentUserMembership the membership to wait for
* @throws TimeoutCancellationException if the room is not available after the timeout
*/
private suspend fun awaitRoom(
roomId: RoomId,
timeout: Duration,
currentUserMembership: CurrentUserMembership,
): RoomInfo {
return withTimeout(timeout) {
getRoomInfoFlow(roomId)
.mapNotNull { roomInfo -> roomInfo.getOrNull() }
.first { info -> info.currentUserMembership == currentUserMembership }
// Ensure that the room is ready
.also { innerClient.awaitRoomRemoteEcho(roomId.value).destroy() }
}
}
override suspend fun findDM(userId: UserId): Result<RoomId?> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.getDmRoom(userId.value)?.use { RoomId(it.id()) }
}
}
override suspend fun getJoinedRoomIds(): Result<Set<RoomId>> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.rooms()
.filter { it.membership() == Membership.JOINED }
.map { RoomId(it.id()) }
.toSet()
}
}
override suspend fun ignoreUser(userId: UserId): Result<Unit> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.ignoreUser(userId.value)
}
}
override suspend fun unignoreUser(userId: UserId): Result<Unit> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.unignoreUser(userId.value)
}
}
override suspend fun createRoom(createRoomParams: CreateRoomParameters): Result<RoomId> = withContext(sessionDispatcher) {
runCatchingExceptions {
val rustParams = RustCreateRoomParameters(
name = createRoomParams.name,
topic = createRoomParams.topic,
isEncrypted = createRoomParams.isEncrypted,
isDirect = createRoomParams.isDirect,
visibility = createRoomParams.visibility.map(),
preset = when (createRoomParams.preset) {
RoomPreset.PRIVATE_CHAT -> RustRoomPreset.PRIVATE_CHAT
RoomPreset.TRUSTED_PRIVATE_CHAT -> RustRoomPreset.TRUSTED_PRIVATE_CHAT
RoomPreset.PUBLIC_CHAT -> RustRoomPreset.PUBLIC_CHAT
},
invite = createRoomParams.invite?.map { it.value },
avatar = createRoomParams.avatar,
powerLevelContentOverride = defaultRoomCreationPowerLevels.copy(
invite = if (createRoomParams.joinRuleOverride == JoinRule.Knock) {
// override the invite power level so it's the same as kick.
RoomMember.Role.Moderator.powerLevel.toInt()
} else {
null
}
),
joinRuleOverride = createRoomParams.joinRuleOverride?.map(),
historyVisibilityOverride = createRoomParams.historyVisibilityOverride?.map(),
canonicalAlias = createRoomParams.roomAliasName.getOrNull(),
)
val roomId = RoomId(innerClient.createRoom(rustParams))
// Wait to receive the room back from the sync but do not returns failure if it fails.
try {
awaitRoom(roomId, 30.seconds, CurrentUserMembership.JOINED)
} catch (e: Exception) {
Timber.e(e, "Timeout waiting for the room to be available in the room list")
}
roomId
}
}
override suspend fun createDM(userId: UserId): Result<RoomId> {
val createRoomParams = CreateRoomParameters(
name = null,
isEncrypted = true,
isDirect = true,
visibility = RoomVisibility.Private,
preset = RoomPreset.TRUSTED_PRIVATE_CHAT,
invite = listOf(userId),
)
return createRoom(createRoomParams)
}
override suspend fun getProfile(userId: UserId): Result<MatrixUser> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.getProfile(userId.value).map()
}
}
override suspend fun getUserProfile(): Result<MatrixUser> = getProfile(sessionId)
.onSuccess { matrixUser ->
_userProfile.emit(matrixUser)
// Also update our session storage
sessionStore.updateUserProfile(
sessionId = sessionId.value,
displayName = matrixUser.displayName,
avatarUrl = matrixUser.avatarUrl,
)
}
override suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults> =
withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.searchUsers(searchTerm, limit.toULong()).let(UserSearchResultMapper::map)
}
}
override suspend fun setDisplayName(displayName: String): Result<Unit> =
withContext(sessionDispatcher) {
runCatchingExceptions { innerClient.setDisplayName(displayName) }
}
override suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result<Unit> =
withContext(sessionDispatcher) {
runCatchingExceptions { innerClient.uploadAvatar(mimeType, data) }
}
override suspend fun removeAvatar(): Result<Unit> =
withContext(sessionDispatcher) {
runCatchingExceptions { innerClient.removeAvatar() }
}
override suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.joinRoomById(roomId.value).destroy()
try {
awaitRoom(roomId, 10.seconds, CurrentUserMembership.JOINED)
} catch (e: Exception) {
Timber.e(e, "Timeout waiting for the room to be available in the room list")
null
}
}
}.mapFailure { it.mapClientException() }
override suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>): Result<RoomInfo?> = withContext(sessionDispatcher) {
runCatchingExceptions {
val roomId = innerClient.joinRoomByIdOrAlias(
roomIdOrAlias = roomIdOrAlias.identifier,
serverNames = serverNames,
).use {
RoomId(it.id())
}
try {
awaitRoom(roomId, 10.seconds, CurrentUserMembership.JOINED)
} catch (e: Exception) {
Timber.e(e, "Timeout waiting for the room to be available in the room list")
null
}
}.mapFailure { it.mapClientException() }
}
override suspend fun knockRoom(roomIdOrAlias: RoomIdOrAlias, message: String, serverNames: List<String>): Result<RoomInfo?> = withContext(
sessionDispatcher
) {
runCatchingExceptions {
val roomId = innerClient.knock(roomIdOrAlias.identifier, message, serverNames).use {
RoomId(it.id())
}
try {
awaitRoom(roomId, 10.seconds, CurrentUserMembership.KNOCKED)
} catch (e: Exception) {
Timber.e(e, "Timeout waiting for the room to be available in the room list")
null
}
}.mapFailure { it.mapClientException() }
}
override suspend fun trackRecentlyVisitedRoom(roomId: RoomId): Result<Unit> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.trackRecentlyVisitedRoom(roomId.value)
}
}
override suspend fun getRecentlyVisitedRooms(): Result<List<RoomId>> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.getRecentlyVisitedRooms().map(::RoomId)
}
}
override suspend fun resolveRoomAlias(roomAlias: RoomAlias): Result<Optional<ResolvedRoomAlias>> = withContext(sessionDispatcher) {
runCatchingExceptions {
val result = innerClient.resolveRoomAlias(roomAlias.value)?.let {
ResolvedRoomAlias(
roomId = RoomId(it.roomId),
servers = it.servers,
)
}
Optional.ofNullable(result)
}
}
override suspend fun getRoomPreview(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>): Result<NotJoinedRoom> = withContext(sessionDispatcher) {
runCatchingExceptions {
when (roomIdOrAlias) {
is RoomIdOrAlias.Alias -> {
val roomId = innerClient.resolveRoomAlias(roomIdOrAlias.roomAlias.value)?.roomId?.let { RoomId(it) }
var room = (roomId?.let { roomFactory.getJoinedRoomOrPreview(it, serverNames) } as? GetRoomResult.NotJoined)?.notJoinedRoom
if (room == null) {
val preview = innerClient.getRoomPreviewFromRoomAlias(roomIdOrAlias.roomAlias.value)
room = NotJoinedRustRoom(sessionId, null, RoomPreviewInfoMapper.map(preview.info()))
}
room
}
is RoomIdOrAlias.Id -> {
var room = (roomFactory.getJoinedRoomOrPreview(roomIdOrAlias.roomId, serverNames) as? GetRoomResult.NotJoined)?.notJoinedRoom
if (room == null) {
val preview = innerClient.getRoomPreviewFromRoomId(roomIdOrAlias.roomId.value, serverNames)
room = NotJoinedRustRoom(sessionId, null, RoomPreviewInfoMapper.map(preview.info()))
}
room
}
}
}.mapFailure { it.mapClientException() }
}
internal suspend fun destroy() {
innerNotificationClient.close()
roomFactory.destroy()
syncService.destroy()
notificationSettingsService.destroy()
notificationProcessSetup.destroy()
sessionCoroutineScope.cancel()
clientDelegateTaskHandle?.cancelAndDestroy()
sessionVerificationService.destroy()
sessionDelegate.clearCurrentClient()
innerRoomListService.close()
innerSpaceService.close()
notificationService.close()
encryptionService.close()
innerClient.close()
}
override suspend fun getCacheSize(): Long {
return getCacheSize(includeCryptoDb = false)
}
override suspend fun clearCache() {
innerClient.clearCaches(innerSyncService)
destroy()
}
override suspend fun logout(userInitiated: Boolean, ignoreSdkError: Boolean) {
sessionCoroutineScope.cancel()
// Remove current delegate so we don't receive an auth error
clientDelegateTaskHandle?.cancelAndDestroy()
clientDelegateTaskHandle = null
withContext(sessionDispatcher) {
if (userInitiated) {
try {
innerClient.logout()
} catch (failure: Throwable) {
if (ignoreSdkError) {
Timber.e(failure, "Fail to call logout on HS. Still delete local files.")
} else {
// If the logout failed we need to restore the delegate
clientDelegateTaskHandle = innerClient.setDelegate(sessionDelegate)
Timber.e(failure, "Fail to call logout on HS.")
throw failure
}
}
}
destroy()
deleteSessionDirectory()
if (userInitiated) {
sessionStore.removeSession(sessionId.value)
}
}
}
override fun canDeactivateAccount(): Boolean {
return runCatchingExceptions {
innerClient.canDeactivateAccount()
}
.getOrNull()
.orFalse()
}
override suspend fun deactivateAccount(password: String, eraseData: Boolean): Result<Unit> = withContext(sessionDispatcher) {
Timber.w("Deactivating account")
// Remove current delegate so we don't receive an auth error
clientDelegateTaskHandle?.cancelAndDestroy()
clientDelegateTaskHandle = null
runCatchingExceptions {
// First call without AuthData, should fail
val firstAttempt = runCatchingExceptions {
innerClient.deactivateAccount(
authData = null,
eraseData = eraseData,
)
}
if (firstAttempt.isFailure) {
Timber.w(firstAttempt.exceptionOrNull(), "Expected failure, try again")
// This is expected, try again with the password
runCatchingExceptions {
innerClient.deactivateAccount(
authData = AuthData.Password(
passwordDetails = AuthDataPasswordDetails(
identifier = sessionId.value,
password = password,
),
),
eraseData = eraseData,
)
}.onFailure {
Timber.e(it, "Failed to deactivate account")
// If the deactivation failed we need to restore the delegate
clientDelegateTaskHandle = innerClient.setDelegate(sessionDelegate)
throw it
}
}
destroy()
deleteSessionDirectory()
sessionStore.removeSession(sessionId.value)
}.onFailure {
Timber.e(it, "Failed to deactivate account")
}
}
override suspend fun getAccountManagementUrl(action: AccountManagementAction?): Result<String?> = withContext(sessionDispatcher) {
val rustAction = action?.toRustAction()
runCatchingExceptions {
innerClient.accountUrl(rustAction)
}
}
override suspend fun uploadMedia(mimeType: String, data: ByteArray): Result<String> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.uploadMedia(mimeType, data, progressWatcher = null)
}
}
override fun getRoomInfoFlow(roomId: RoomId): Flow<Optional<RoomInfo>> {
return mxCallbackFlow {
val roomNotFound = innerRoomListService.roomOrNull(roomId.value).use { it == null }
if (roomNotFound) {
channel.send(Optional.empty())
}
innerClient.subscribeToRoomInfo(roomId.value, object : RoomInfoListener {
override fun call(roomInfo: org.matrix.rustcomponents.sdk.RoomInfo) {
val mappedRoomInfo = roomInfoMapper.map(roomInfo)
channel.trySend(Optional.of(mappedRoomInfo))
}
})
}.distinctUntilChanged()
}
override suspend fun setAllSendQueuesEnabled(enabled: Boolean) {
withContext(sessionDispatcher) {
Timber.i("setAllSendQueuesEnabled($enabled)")
tryOrNull {
innerClient.enableAllSendQueues(enabled)
}
}
}
override fun sendQueueDisabledFlow(): Flow<RoomId> = mxCallbackFlow {
innerClient.subscribeToSendQueueStatus(object : SendQueueRoomErrorListener {
override fun onError(roomId: String, error: ClientException) {
trySend(RoomId(roomId))
}
})
}.buffer(Channel.UNLIMITED)
override suspend fun currentSlidingSyncVersion(): Result<SlidingSyncVersion> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.session().slidingSyncVersion.map()
}
}
override suspend fun canReportRoom(): Boolean = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.isReportRoomApiSupported()
}.getOrDefault(false)
}
override suspend fun isLivekitRtcSupported(): Boolean = withContext(sessionDispatcher) {
innerClient.isLivekitRtcSupported()
}
override suspend fun getMaxFileUploadSize(): Result<Long> = withContext(sessionDispatcher) {
runCatchingExceptions { innerClient.getMaxMediaUploadSize().toLong() }
}
override suspend fun addRecentEmoji(emoji: String): Result<Unit> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.addRecentEmoji(emoji)
}
}
override suspend fun getRecentEmojis(): Result<List<String>> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.getRecentEmojis().map { it.emoji }
}
}
override suspend fun markRoomAsFullyRead(roomId: RoomId, eventId: EventId): Result<Unit> = withContext(sessionDispatcher) {
runCatchingExceptions {
val room = innerClient.getRoom(roomId.value) ?: error("Could not fetch associated room")
room.markAsFullyReadUnchecked(eventId.value)
}
}
private suspend fun getCacheSize(
includeCryptoDb: Boolean = false,
): Long = withContext(sessionDispatcher) {
val sessionDirectory = sessionPathsProvider.provides(sessionId) ?: return@withContext 0L
val cacheSize = sessionDirectory.cacheDirectory.getSizeOfFiles()
if (includeCryptoDb) {
cacheSize + sessionDirectory.fileDirectory.getSizeOfFiles()
} else {
cacheSize + listOf(
"matrix-sdk-state.sqlite3",
"matrix-sdk-state.sqlite3-shm",
"matrix-sdk-state.sqlite3-wal",
).map { fileName ->
File(sessionDirectory.fileDirectory, fileName)
}.sumOf { file ->
file.length()
}
}
}
private suspend fun deleteSessionDirectory() = withContext(sessionDispatcher) {
// Delete all the files for this session
sessionPathsProvider.provides(sessionId)?.deleteRecursively()
}
}
private val defaultRoomCreationPowerLevels = PowerLevels(
usersDefault = null,
eventsDefault = null,
stateDefault = null,
ban = null,
kick = null,
redact = null,
invite = null,
notifications = null,
users = mapOf(),
events = mapOf(
"m.call.member" to 0,
"org.matrix.msc3401.call.member" to 0,
)
)
@@ -0,0 +1,201 @@
/*
* 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.impl
import dev.zacsweers.metro.Inject
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.data.ByteUnit
import io.element.android.libraries.core.data.megaBytes
import io.element.android.libraries.di.CacheDirectory
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.impl.analytics.UtdTracker
import io.element.android.libraries.matrix.impl.certificates.UserCertificatesProvider
import io.element.android.libraries.matrix.impl.paths.SessionPaths
import io.element.android.libraries.matrix.impl.paths.getSessionPaths
import io.element.android.libraries.matrix.impl.proxy.ProxyProvider
import io.element.android.libraries.matrix.impl.room.TimelineEventTypeFilterFactory
import io.element.android.libraries.matrix.impl.util.anonymizedTokens
import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.libraries.sessionstorage.api.SessionData
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientBuilder
import org.matrix.rustcomponents.sdk.RequestConfig
import org.matrix.rustcomponents.sdk.Session
import org.matrix.rustcomponents.sdk.SlidingSyncVersion
import org.matrix.rustcomponents.sdk.SlidingSyncVersionBuilder
import org.matrix.rustcomponents.sdk.SqliteStoreBuilder
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import uniffi.matrix_sdk_base.MediaRetentionPolicy
import uniffi.matrix_sdk_crypto.CollectStrategy
import uniffi.matrix_sdk_crypto.DecryptionSettings
import uniffi.matrix_sdk_crypto.TrustRequirement
import java.io.File
import kotlin.time.Duration.Companion.days
import kotlin.time.toJavaDuration
@Inject
class RustMatrixClientFactory(
@CacheDirectory private val cacheDirectory: File,
@AppCoroutineScope
private val appCoroutineScope: CoroutineScope,
private val coroutineDispatchers: CoroutineDispatchers,
private val sessionStore: SessionStore,
private val userAgentProvider: UserAgentProvider,
private val userCertificatesProvider: UserCertificatesProvider,
private val proxyProvider: ProxyProvider,
private val clock: SystemClock,
private val analyticsService: AnalyticsService,
private val featureFlagService: FeatureFlagService,
private val timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory,
private val clientBuilderProvider: ClientBuilderProvider,
) {
private val sessionDelegate = RustClientSessionDelegate(sessionStore, appCoroutineScope, coroutineDispatchers)
suspend fun create(sessionData: SessionData): RustMatrixClient = withContext(coroutineDispatchers.io) {
val client = getBaseClientBuilder(
sessionPaths = sessionData.getSessionPaths(),
passphrase = sessionData.passphrase,
slidingSyncType = ClientBuilderSlidingSync.Restored,
)
.homeserverUrl(sessionData.homeserverUrl)
.username(sessionData.userId)
.use { it.build() }
client.setMediaRetentionPolicy(
MediaRetentionPolicy(
// Make this 500MB instead of 400MB
maxCacheSize = 500.megaBytes.to(ByteUnit.BYTES).toULong(),
// This is the default value, but let's make it explicit
maxFileSize = 20.megaBytes.to(ByteUnit.BYTES).toULong(),
// Use 30 days instead of 60
lastAccessExpiry = 30.days.toJavaDuration(),
// This is the default value, but let's make it explicit
cleanupFrequency = 1.days.toJavaDuration(),
)
)
client.restoreSession(sessionData.toSession())
create(client)
}
suspend fun create(client: Client): RustMatrixClient {
val (anonymizedAccessToken, anonymizedRefreshToken) = client.session().anonymizedTokens()
client.setUtdDelegate(UtdTracker(analyticsService))
val syncService = client.syncService()
.withSharePos(true)
.withOfflineMode()
.finish()
return RustMatrixClient(
innerClient = client,
sessionStore = sessionStore,
appCoroutineScope = appCoroutineScope,
sessionDelegate = sessionDelegate,
innerSyncService = syncService,
dispatchers = coroutineDispatchers,
baseCacheDirectory = cacheDirectory,
clock = clock,
timelineEventTypeFilterFactory = timelineEventTypeFilterFactory,
featureFlagService = featureFlagService,
analyticsService = analyticsService,
).also {
Timber.tag(it.toString()).d("Creating Client with access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'")
}
}
internal suspend fun getBaseClientBuilder(
sessionPaths: SessionPaths,
passphrase: String?,
slidingSyncType: ClientBuilderSlidingSync,
): ClientBuilder {
return clientBuilderProvider.provide()
.sqliteStore(
SqliteStoreBuilder(
dataPath = sessionPaths.fileDirectory.absolutePath,
cachePath = sessionPaths.cacheDirectory.absolutePath,
).passphrase(passphrase)
)
.setSessionDelegate(sessionDelegate)
.userAgent(userAgentProvider.provide())
.addRootCertificates(userCertificatesProvider.provides())
.autoEnableBackups(true)
.autoEnableCrossSigning(true)
.roomKeyRecipientStrategy(
strategy = if (featureFlagService.isFeatureEnabled(FeatureFlags.OnlySignedDeviceIsolationMode)) {
CollectStrategy.IDENTITY_BASED_STRATEGY
} else {
CollectStrategy.ERROR_ON_VERIFIED_USER_PROBLEM
}
)
.decryptionSettings(
DecryptionSettings(
senderDeviceTrustRequirement = if (featureFlagService.isFeatureEnabled(FeatureFlags.OnlySignedDeviceIsolationMode)) {
TrustRequirement.CROSS_SIGNED_OR_LEGACY
} else {
TrustRequirement.UNTRUSTED
}
)
)
.enableShareHistoryOnInvite(featureFlagService.isFeatureEnabled(FeatureFlags.EnableKeyShareOnInvite))
.threadsEnabled(featureFlagService.isFeatureEnabled(FeatureFlags.Threads), threadSubscriptions = false)
.requestConfig(
RequestConfig(
timeout = 30_000uL,
retryLimit = 0u,
// Use default values for the rest
maxConcurrentRequests = null,
maxRetryTime = null,
)
)
.run {
// Apply sliding sync version settings
when (slidingSyncType) {
ClientBuilderSlidingSync.Restored -> this
ClientBuilderSlidingSync.Discovered -> slidingSyncVersionBuilder(SlidingSyncVersionBuilder.DISCOVER_NATIVE)
ClientBuilderSlidingSync.Native -> slidingSyncVersionBuilder(SlidingSyncVersionBuilder.NATIVE)
}
}
.run {
// Workaround for non-nullable proxy parameter in the SDK, since each call to the ClientBuilder returns a new reference we need to keep
proxyProvider.provides()?.let { proxy(it) } ?: this
}
}
}
sealed interface ClientBuilderSlidingSync {
// The proxy will be supplied when restoring the Session.
data object Restored : ClientBuilderSlidingSync
// A Native Sliding Sync instance must be discovered whilst building the session.
data object Discovered : ClientBuilderSlidingSync
// Force using Native Sliding Sync.
data object Native : ClientBuilderSlidingSync
}
private fun SessionData.toSession() = Session(
accessToken = accessToken,
refreshToken = refreshToken,
userId = userId,
deviceId = deviceId,
homeserverUrl = homeserverUrl,
slidingSyncVersion = SlidingSyncVersion.NATIVE,
oidcData = oidcData,
)
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.api.SdkMetadata
import org.matrix.rustcomponents.sdk.sdkGitSha
@ContributesBinding(AppScope::class)
class RustSdkMetadata : SdkMetadata {
override val sdkGitSha: String
get() = sdkGitSha()
}
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.analytics
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.isDm
import kotlinx.coroutines.flow.first
private fun Long.toAnalyticsRoomSize(): JoinedRoom.RoomSize {
return when (this) {
0L,
1L -> JoinedRoom.RoomSize.One
2L -> JoinedRoom.RoomSize.Two
in 3..10 -> JoinedRoom.RoomSize.ThreeToTen
in 11..100 -> JoinedRoom.RoomSize.ElevenToOneHundred
in 101..1000 -> JoinedRoom.RoomSize.OneHundredAndOneToAThousand
else -> JoinedRoom.RoomSize.MoreThanAThousand
}
}
suspend fun BaseRoom.toAnalyticsJoinedRoom(trigger: JoinedRoom.Trigger?): JoinedRoom {
val roomInfo = roomInfoFlow.first()
return roomInfo.toAnalyticsJoinedRoom(trigger)
}
fun RoomInfo.toAnalyticsJoinedRoom(trigger: JoinedRoom.Trigger?): JoinedRoom {
return JoinedRoom(
isDM = isDm,
isSpace = isSpace,
roomSize = joinedMembersCount.toAnalyticsRoomSize(),
trigger = trigger
)
}
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.analytics
import im.vector.app.features.analytics.plan.Error
import io.element.android.services.analytics.api.AnalyticsService
import org.matrix.rustcomponents.sdk.UnableToDecryptDelegate
import org.matrix.rustcomponents.sdk.UnableToDecryptInfo
import timber.log.Timber
import uniffi.matrix_sdk_crypto.UtdCause
class UtdTracker(
private val analyticsService: AnalyticsService,
) : UnableToDecryptDelegate {
override fun onUtd(info: UnableToDecryptInfo) {
Timber.d("onUtd for event ${info.eventId}, timeToDecryptMs: ${info.timeToDecryptMs}")
val name = when (info.cause) {
UtdCause.UNKNOWN -> Error.Name.OlmKeysNotSentError
UtdCause.SENT_BEFORE_WE_JOINED -> Error.Name.ExpectedDueToMembership
UtdCause.VERIFICATION_VIOLATION -> Error.Name.ExpectedVerificationViolation
UtdCause.UNSIGNED_DEVICE,
UtdCause.UNKNOWN_DEVICE -> {
Error.Name.ExpectedSentByInsecureDevice
}
UtdCause.HISTORICAL_MESSAGE_AND_BACKUP_IS_DISABLED,
UtdCause.HISTORICAL_MESSAGE_AND_DEVICE_IS_UNVERIFIED,
-> Error.Name.HistoricalMessage
UtdCause.WITHHELD_FOR_UNVERIFIED_OR_INSECURE_DEVICE -> Error.Name.RoomKeysWithheldForUnverifiedDevice
UtdCause.WITHHELD_BY_SENDER -> Error.Name.OlmKeysNotSentError
}
val event = Error(
context = null,
// Keep cryptoModule for compatibility.
cryptoModule = Error.CryptoModule.Rust,
cryptoSDK = Error.CryptoSDK.Rust,
timeToDecryptMillis = info.timeToDecryptMs?.toInt() ?: -1,
domain = Error.Domain.E2EE,
name = name,
eventLocalAgeMillis = info.eventLocalAgeMillis.toInt(),
userTrustsOwnIdentity = info.userTrustsOwnIdentity,
isFederated = info.ownHomeserver != info.senderHomeserver,
isMatrixDotOrg = info.ownHomeserver == "matrix.org",
)
analyticsService.capture(event)
}
}
@@ -0,0 +1,38 @@
/*
* 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.impl.auth
import io.element.android.libraries.matrix.api.auth.AuthenticationException
import org.matrix.rustcomponents.sdk.ClientBuildException
import org.matrix.rustcomponents.sdk.OidcException
fun Throwable.mapAuthenticationException(): AuthenticationException {
return when (this) {
is AuthenticationException -> this
is ClientBuildException -> when (this) {
is ClientBuildException.Generic -> AuthenticationException.Generic(message)
is ClientBuildException.InvalidServerName -> AuthenticationException.InvalidServerName(message)
is ClientBuildException.SlidingSyncVersion -> AuthenticationException.SlidingSyncVersion(message)
is ClientBuildException.Sdk -> AuthenticationException.Generic(message)
is ClientBuildException.ServerUnreachable -> AuthenticationException.ServerUnreachable(message)
is ClientBuildException.SlidingSync -> AuthenticationException.Generic(message)
is ClientBuildException.WellKnownDeserializationException -> AuthenticationException.Generic(message)
is ClientBuildException.WellKnownLookupFailed -> AuthenticationException.Generic(message)
is ClientBuildException.EventCache -> AuthenticationException.Generic(message)
}
is OidcException -> when (this) {
is OidcException.Generic -> AuthenticationException.Oidc(message)
is OidcException.CallbackUrlInvalid -> AuthenticationException.Oidc(message)
is OidcException.Cancelled -> AuthenticationException.Oidc(message)
is OidcException.MetadataInvalid -> AuthenticationException.Oidc(message)
is OidcException.NotSupported -> AuthenticationException.Oidc(message)
}
else -> AuthenticationException.Generic(message)
}
}
@@ -0,0 +1,20 @@
/*
* 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.impl.auth
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import org.matrix.rustcomponents.sdk.HomeserverLoginDetails
fun HomeserverLoginDetails.map(): MatrixHomeServerDetails = use {
MatrixHomeServerDetails(
url = url(),
supportsPasswordLogin = supportsPasswordLogin(),
supportsOidcLogin = supportsOidcLogin(),
)
}
@@ -0,0 +1,31 @@
/*
* 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.impl.auth
import dev.zacsweers.metro.Inject
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.auth.OidcConfig
import io.element.android.libraries.matrix.api.auth.OidcRedirectUrlProvider
import org.matrix.rustcomponents.sdk.OidcConfiguration
@Inject
class OidcConfigurationProvider(
private val buildMeta: BuildMeta,
private val oidcRedirectUrlProvider: OidcRedirectUrlProvider,
) {
fun get(): OidcConfiguration = OidcConfiguration(
clientName = buildMeta.applicationName,
redirectUri = oidcRedirectUrlProvider.provide(),
clientUri = OidcConfig.CLIENT_URI,
logoUri = OidcConfig.LOGO_URI,
tosUri = OidcConfig.TOS_URI,
policyUri = OidcConfig.POLICY_URI,
staticRegistrations = OidcConfig.STATIC_REGISTRATIONS,
)
}
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.auth
import io.element.android.libraries.matrix.api.auth.OidcPrompt
import org.matrix.rustcomponents.sdk.OidcPrompt as RustOidcPrompt
internal fun OidcPrompt.toRustPrompt(): RustOidcPrompt {
return when (this) {
OidcPrompt.Login -> RustOidcPrompt.Unknown("consent")
OidcPrompt.Create -> RustOidcPrompt.Create
is OidcPrompt.Unknown -> RustOidcPrompt.Unknown(value)
}
}
@@ -0,0 +1,38 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.impl.auth
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker
import io.element.android.libraries.matrix.impl.ClientBuilderProvider
import io.element.android.libraries.matrix.impl.certificates.UserCertificatesProvider
import timber.log.Timber
@ContributesBinding(AppScope::class)
class RustHomeServerLoginCompatibilityChecker(
private val clientBuilderProvider: ClientBuilderProvider,
private val userCertificatesProvider: UserCertificatesProvider,
) : HomeServerLoginCompatibilityChecker {
override suspend fun check(url: String): Result<Boolean> = runCatchingExceptions {
clientBuilderProvider.provide()
.inMemoryStore()
.serverNameOrHomeserverUrl(url)
.addRootCertificates(userCertificatesProvider.provides())
.build()
.use {
it.homeserverLoginDetails()
}
.use {
Timber.d("Homeserver $url | OIDC: ${it.supportsOidcLogin()} | Password: ${it.supportsPasswordLogin()} | SSO: ${it.supportsSsoLogin()}")
it.supportsOidcLogin() || it.supportsPasswordLogin()
}
}
}
@@ -0,0 +1,358 @@
/*
* 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.impl.auth
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
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.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.matrix.api.auth.OidcPrompt
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.impl.ClientBuilderSlidingSync
import io.element.android.libraries.matrix.impl.RustMatrixClientFactory
import io.element.android.libraries.matrix.impl.auth.qrlogin.QrErrorMapper
import io.element.android.libraries.matrix.impl.auth.qrlogin.SdkQrCodeLoginData
import io.element.android.libraries.matrix.impl.auth.qrlogin.toStep
import io.element.android.libraries.matrix.impl.exception.mapClientException
import io.element.android.libraries.matrix.impl.keys.PassphraseGenerator
import io.element.android.libraries.matrix.impl.mapper.toSessionData
import io.element.android.libraries.matrix.impl.paths.SessionPaths
import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory
import io.element.android.libraries.sessionstorage.api.LoginType
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientBuilder
import org.matrix.rustcomponents.sdk.HumanQrLoginException
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 timber.log.Timber
import uniffi.matrix_sdk.OAuthAuthorizationData
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class RustMatrixAuthenticationService(
private val sessionPathsFactory: SessionPathsFactory,
private val coroutineDispatchers: CoroutineDispatchers,
private val sessionStore: SessionStore,
private val rustMatrixClientFactory: RustMatrixClientFactory,
private val passphraseGenerator: PassphraseGenerator,
private val oidcConfigurationProvider: OidcConfigurationProvider,
) : MatrixAuthenticationService {
// Passphrase which will be used for new sessions. Existing sessions will use the passphrase
// stored in the SessionData.
private val pendingPassphrase = getDatabasePassphrase()
// Need to keep a copy of the current session path to eventually delete it.
// Ideally it would be possible to get the sessionPath from the Client to avoid doing this.
private var sessionPaths: SessionPaths? = null
private var currentClient: Client? = null
private val newMatrixClientObservers = mutableListOf<(MatrixClient) -> Unit>()
override fun listenToNewMatrixClients(lambda: (MatrixClient) -> Unit) {
newMatrixClientObservers.add(lambda)
}
private fun rotateSessionPath(): SessionPaths {
sessionPaths?.deleteRecursively()
return sessionPathsFactory.create()
.also { sessionPaths = it }
}
override suspend fun restoreSession(sessionId: SessionId): Result<MatrixClient> = withContext(coroutineDispatchers.io) {
runCatchingExceptions {
val sessionData = sessionStore.getSession(sessionId.value)
if (sessionData != null) {
if (sessionData.isTokenValid) {
// Use the sessionData.passphrase, which can be null for a previously created session
if (sessionData.passphrase == null) {
Timber.w("Restoring a session without a passphrase")
} else {
Timber.w("Restoring a session with a passphrase")
}
rustMatrixClientFactory.create(sessionData)
} else {
error("Token is not valid")
}
} else {
error("No session to restore with id $sessionId")
}
}.mapFailure { failure ->
failure.mapClientException()
}
}
private fun getDatabasePassphrase(): String? {
val passphrase = passphraseGenerator.generatePassphrase()
if (passphrase != null) {
Timber.w("New sessions will be encrypted with a passphrase")
}
return passphrase
}
override suspend fun setHomeserver(homeserver: String): Result<MatrixHomeServerDetails> =
withContext(coroutineDispatchers.io) {
val emptySessionPath = rotateSessionPath()
runCatchingExceptions {
val client = makeClient(sessionPaths = emptySessionPath) {
serverNameOrHomeserverUrl(homeserver)
}
currentClient = client
client.homeserverLoginDetails().map()
}.onFailure {
clear()
}.mapFailure { failure ->
Timber.e(failure, "Failed to set homeserver to $homeserver")
failure.mapAuthenticationException()
}
}
override suspend fun login(username: String, password: String): Result<SessionId> =
withContext(coroutineDispatchers.io) {
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)
// Ensure that the user is not already logged in with the same account
ensureNotAlreadyLoggedIn(client)
val sessionData = client.session()
.toSessionData(
isTokenValid = true,
loginType = LoginType.PASSWORD,
passphrase = pendingPassphrase,
sessionPaths = currentSessionPaths,
)
val matrixClient = rustMatrixClientFactory.create(client)
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
sessionStore.addSession(sessionData)
// Clean up the strong reference held here since it's no longer necessary
currentClient = null
SessionId(sessionData.userId)
}.mapFailure { failure ->
Timber.e(failure, "Failed to login")
failure.mapAuthenticationException()
}
}
override suspend fun importCreatedSession(externalSession: ExternalSession): Result<SessionId> =
withContext(coroutineDispatchers.io) {
runCatchingExceptions {
currentClient ?: error("You need to call `setHomeserver()` first")
val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first")
val sessionData = externalSession.toSessionData(
isTokenValid = true,
loginType = LoginType.PASSWORD,
passphrase = pendingPassphrase,
sessionPaths = currentSessionPaths,
)
clear()
sessionStore.addSession(sessionData)
SessionId(sessionData.userId)
}
}
private var pendingOAuthAuthorizationData: OAuthAuthorizationData? = null
override suspend fun getOidcUrl(
prompt: OidcPrompt,
loginHint: String?,
): Result<OidcDetails> {
return withContext(coroutineDispatchers.io) {
runCatchingExceptions {
val client = currentClient ?: error("You need to call `setHomeserver()` first")
val oAuthAuthorizationData = client.urlForOidc(
oidcConfiguration = oidcConfigurationProvider.get(),
prompt = prompt.toRustPrompt(),
loginHint = loginHint,
// If we want to restore a previous session for which we have encryption keys, we can pass the deviceId here. At the moment, we don't
deviceId = null,
additionalScopes = emptyList(),
)
val url = oAuthAuthorizationData.loginUrl()
pendingOAuthAuthorizationData = oAuthAuthorizationData
OidcDetails(url)
}.mapFailure { failure ->
Timber.e(failure, "Failed to get OIDC URL")
failure.mapAuthenticationException()
}
}
}
override suspend fun cancelOidcLogin(): Result<Unit> {
return withContext(coroutineDispatchers.io) {
runCatchingExceptions {
pendingOAuthAuthorizationData?.use {
currentClient?.abortOidcAuth(it)
}
pendingOAuthAuthorizationData = null
}.mapFailure { failure ->
Timber.e(failure, "Failed to cancel OIDC login")
failure.mapAuthenticationException()
}
}
}
/**
* callbackUrl should be the uriRedirect from OidcClientMetadata (with all the parameters).
*/
override suspend fun loginWithOidc(callbackUrl: String): Result<SessionId> {
return withContext(coroutineDispatchers.io) {
runCatchingExceptions {
val client = currentClient ?: error("You need to call `setHomeserver()` first")
val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first")
client.loginWithOidcCallback(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)
val sessionData = client.session().toSessionData(
isTokenValid = true,
loginType = LoginType.OIDC,
passphrase = pendingPassphrase,
sessionPaths = currentSessionPaths,
)
val matrixClient = rustMatrixClientFactory.create(client)
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
sessionStore.addSession(sessionData)
// Clean up the strong reference held here since it's no longer necessary
currentClient = null
SessionId(sessionData.userId)
}.mapFailure { failure ->
Timber.e(failure, "Failed to login with OIDC")
failure.mapAuthenticationException()
}
}
}
@Throws(AuthenticationException.AccountAlreadyLoggedIn::class)
private suspend fun ensureNotAlreadyLoggedIn(client: Client) {
val newUserId = client.userId()
val accountAlreadyLoggedIn = sessionStore.getAllSessions().any {
it.userId == newUserId
}
if (accountAlreadyLoggedIn) {
// Sign out the client, ignoring any error
runCatchingExceptions {
client.logout()
}
throw AuthenticationException.AccountAlreadyLoggedIn(newUserId)
}
}
override suspend fun loginWithQrCode(qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit) =
withContext(coroutineDispatchers.io) {
val sdkQrCodeLoginData = (qrCodeData as SdkQrCodeLoginData).rustQrCodeData
val emptySessionPaths = rotateSessionPath()
val oidcConfiguration = oidcConfigurationProvider.get()
val progressListener = object : QrLoginProgressListener {
override fun onUpdate(state: QrLoginProgress) {
Timber.d("QR Code login progress: $state")
progress(state.toStep())
}
}
runCatchingExceptions {
val client = makeQrCodeLoginClient(
sessionPaths = emptySessionPaths,
qrCodeData = sdkQrCodeLoginData,
)
client.newLoginWithQrCodeHandler(
oidcConfiguration = oidcConfiguration,
).use {
it.scan(
qrCodeData = qrCodeData.rustQrCodeData,
progressListener = progressListener,
)
}
// Ensure that the user is not already logged in with the same account
ensureNotAlreadyLoggedIn(client)
val sessionData = client.session()
.toSessionData(
isTokenValid = true,
loginType = LoginType.QR,
passphrase = pendingPassphrase,
sessionPaths = emptySessionPaths,
)
val matrixClient = rustMatrixClientFactory.create(client)
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
sessionStore.addSession(sessionData)
// Clean up the strong reference held here since it's no longer necessary
currentClient = null
SessionId(sessionData.userId)
}.mapFailure {
when (it) {
is QrCodeDecodeException -> QrErrorMapper.map(it)
is HumanQrLoginException -> QrErrorMapper.map(it)
else -> it
}
}.onFailure { throwable ->
if (throwable is CancellationException) {
throw throwable
}
Timber.e(throwable, "Failed to login with QR code")
}
}
private suspend fun makeClient(
sessionPaths: SessionPaths,
config: suspend ClientBuilder.() -> ClientBuilder,
): Client {
Timber.d("Creating client with simplified sliding sync")
return rustMatrixClientFactory
.getBaseClientBuilder(
sessionPaths = sessionPaths,
passphrase = pendingPassphrase,
slidingSyncType = ClientBuilderSlidingSync.Discovered,
)
.config()
.build()
}
private suspend fun makeQrCodeLoginClient(
sessionPaths: SessionPaths,
qrCodeData: QrCodeData,
): Client {
Timber.d("Creating client for QR Code login with simplified sliding sync")
return rustMatrixClientFactory
.getBaseClientBuilder(
sessionPaths = sessionPaths,
passphrase = pendingPassphrase,
slidingSyncType = ClientBuilderSlidingSync.Discovered,
)
.serverNameOrHomeserverUrl(qrCodeData.serverName()!!)
.build()
}
private fun clear() {
currentClient?.close()
currentClient = null
}
}
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.auth.qrlogin
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeDecodeException
import io.element.android.libraries.matrix.api.auth.qrlogin.QrLoginException
import org.matrix.rustcomponents.sdk.HumanQrLoginException as RustHumanQrLoginException
import org.matrix.rustcomponents.sdk.QrCodeDecodeException as RustQrCodeDecodeException
object QrErrorMapper {
fun map(qrCodeDecodeException: RustQrCodeDecodeException): QrCodeDecodeException = when (qrCodeDecodeException) {
is RustQrCodeDecodeException.Crypto -> {
// We plan to restore it in the future when UniFFi can process them
// val reason = when (qrCodeDecodeException.error) {
// LoginQrCodeDecodeError.NOT_ENOUGH_DATA -> QrCodeDecodeException.Crypto.Reason.NOT_ENOUGH_DATA
// LoginQrCodeDecodeError.NOT_UTF8 -> QrCodeDecodeException.Crypto.Reason.NOT_UTF8
// LoginQrCodeDecodeError.URL_PARSE -> QrCodeDecodeException.Crypto.Reason.URL_PARSE
// LoginQrCodeDecodeError.INVALID_MODE -> QrCodeDecodeException.Crypto.Reason.INVALID_MODE
// LoginQrCodeDecodeError.INVALID_VERSION -> QrCodeDecodeException.Crypto.Reason.INVALID_VERSION
// LoginQrCodeDecodeError.BASE64 -> QrCodeDecodeException.Crypto.Reason.BASE64
// LoginQrCodeDecodeError.INVALID_PREFIX -> QrCodeDecodeException.Crypto.Reason.INVALID_PREFIX
// }
QrCodeDecodeException.Crypto(
qrCodeDecodeException.message.orEmpty(),
// reason
)
}
}
fun map(humanQrLoginError: RustHumanQrLoginException): QrLoginException = when (humanQrLoginError) {
is RustHumanQrLoginException.Cancelled -> QrLoginException.Cancelled
is RustHumanQrLoginException.ConnectionInsecure -> QrLoginException.ConnectionInsecure
is RustHumanQrLoginException.Declined -> QrLoginException.Declined
is RustHumanQrLoginException.Expired -> QrLoginException.Expired
is RustHumanQrLoginException.OtherDeviceNotSignedIn -> QrLoginException.OtherDeviceNotSignedIn
is RustHumanQrLoginException.LinkingNotSupported -> QrLoginException.LinkingNotSupported
is RustHumanQrLoginException.Unknown -> QrLoginException.Unknown
is RustHumanQrLoginException.OidcMetadataInvalid -> QrLoginException.OidcMetadataInvalid
is RustHumanQrLoginException.SlidingSyncNotAvailable -> QrLoginException.SlidingSyncNotAvailable
is RustHumanQrLoginException.CheckCodeAlreadySent -> QrLoginException.CheckCodeAlreadySent
is RustHumanQrLoginException.CheckCodeCannotBeSent -> QrLoginException.CheckCodeCannotBeSent
is RustHumanQrLoginException.NotFound -> QrLoginException.NotFound
}
}
@@ -0,0 +1,22 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.auth.qrlogin
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import org.matrix.rustcomponents.sdk.QrLoginProgress
fun QrLoginProgress.toStep(): QrCodeLoginStep {
return when (this) {
is QrLoginProgress.EstablishingSecureChannel -> QrCodeLoginStep.EstablishingSecureChannel(checkCodeString)
is QrLoginProgress.Starting -> QrCodeLoginStep.Starting
is QrLoginProgress.WaitingForToken -> QrCodeLoginStep.WaitingForToken(userCode)
is QrLoginProgress.SyncingSecrets -> QrCodeLoginStep.SyncingSecrets
is QrLoginProgress.Done -> QrCodeLoginStep.Finished
}
}
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.auth.qrlogin
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginDataFactory
import org.matrix.rustcomponents.sdk.QrCodeData
@ContributesBinding(AppScope::class)
class RustQrCodeLoginDataFactory : MatrixQrCodeLoginDataFactory {
override fun parseQrCodeData(data: ByteArray): Result<MatrixQrCodeLoginData> {
return runCatchingExceptions { SdkQrCodeLoginData(QrCodeData.fromBytes(data)) }
}
}
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.auth.qrlogin
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import org.matrix.rustcomponents.sdk.QrCodeData as RustQrCodeData
class SdkQrCodeLoginData(
internal val rustQrCodeData: RustQrCodeData,
) : MatrixQrCodeLoginData {
override fun serverName(): String? {
return rustQrCodeData.serverName()
}
}
@@ -0,0 +1,77 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.certificates
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import timber.log.Timber
import java.security.KeyStore
import java.security.KeyStoreException
@ContributesBinding(AppScope::class)
class DefaultUserCertificatesProvider : UserCertificatesProvider {
/**
* Get additional user-installed certificates from the `AndroidCAStore` `Keystore`.
*
* The Rust HTTP client doesn't include user-installed certificates in its internal certificate
* store. This means that whatever the user installs will be ignored.
*
* While most users don't need user-installed certificates some special deployments or debugging
* setups using a proxy might want to use them.
*
* @return A list of byte arrays where each byte array is a single user-installed certificate
* in encoded form.
*/
override fun provides(): List<ByteArray> {
// At least for API 34 the `AndroidCAStore` `Keystore` type contained user certificates as well.
// I have not found this to be documented anywhere.
val keyStore: KeyStore = try {
KeyStore.getInstance("AndroidCAStore")
} catch (e: KeyStoreException) {
Timber.w(e, "Failed to get AndroidCAStore keystore")
return emptyList()
}
val aliases = try {
keyStore.load(null)
keyStore.aliases()
} catch (e: Exception) {
Timber.w(e, "Failed to load and get aliases AndroidCAStore keystore")
return emptyList()
}
return aliases.toList()
.filter { alias ->
// The certificate alias always contains the prefix `system` or
// `user` and the MD5 subject hash separated by a colon.
//
// The subject hash can be calculated using openssl as such:
// openssl x509 -subject_hash_old -noout -in mycert.cer
//
// Again, I have not found this to be documented somewhere.
alias.startsWith("user")
}
.mapNotNull { alias ->
try {
keyStore.getEntry(alias, null)
} catch (e: Exception) {
Timber.w(e, "Failed to get entry for alias $alias")
null
}
}
.filterIsInstance<KeyStore.TrustedCertificateEntry>()
.map { trustedCertificateEntry ->
trustedCertificateEntry.trustedCertificate.encoded
}
.also {
// Let's at least log the number of user-installed certificates we found,
// since the alias isn't particularly useful nor does the issuer seem to
// be easily available.
Timber.i("Found ${it.size} additional user-provided certificates.")
}
}
}
@@ -0,0 +1,13 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.certificates
interface UserCertificatesProvider {
fun provides(): List<ByteArray>
}
@@ -0,0 +1,23 @@
/*
* 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.impl.core
import io.element.android.libraries.matrix.api.core.ProgressCallback
import org.matrix.rustcomponents.sdk.ProgressWatcher
import org.matrix.rustcomponents.sdk.TransmissionProgress
internal class ProgressWatcherWrapper(private val progressCallback: ProgressCallback) : ProgressWatcher {
override fun transmissionProgress(progress: TransmissionProgress) {
progressCallback.onProgress(progress.current.toLong(), progress.total.toLong())
}
}
internal fun ProgressCallback.toProgressWatcher(): ProgressWatcher {
return ProgressWatcherWrapper(this)
}
@@ -0,0 +1,22 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.core
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.SendHandle
class RustSendHandle(
val inner: org.matrix.rustcomponents.sdk.SendHandle,
) : SendHandle {
override suspend fun retry(): Result<Unit> {
return runCatchingExceptions {
inner.tryResend()
}
}
}
@@ -0,0 +1,27 @@
/*
* 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.impl.di
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.annotations.RoomCoroutineScope
import io.element.android.libraries.matrix.api.room.BaseRoom
import kotlinx.coroutines.CoroutineScope
@BindingContainer
@ContributesTo(RoomScope::class)
object RoomModule {
@RoomCoroutineScope
@Provides
fun providesSessionCoroutineScope(room: BaseRoom): CoroutineScope {
return room.roomCoroutineScope
}
}
@@ -0,0 +1,93 @@
/*
* 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.impl.di
import dev.zacsweers.metro.BindingContainer
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.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaPreviewService
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.spaces.SpaceService
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import kotlinx.coroutines.CoroutineScope
@BindingContainer
@ContributesTo(SessionScope::class)
object SessionMatrixModule {
@Provides
fun providesSessionId(matrixClient: MatrixClient): SessionId {
return matrixClient.sessionId
}
@Provides
fun providesSessionVerificationService(matrixClient: MatrixClient): SessionVerificationService {
return matrixClient.sessionVerificationService
}
@Provides
fun providesNotificationSettingsService(matrixClient: MatrixClient): NotificationSettingsService {
return matrixClient.notificationSettingsService
}
@Provides
fun provideRoomMembershipObserver(matrixClient: MatrixClient): RoomMembershipObserver {
return matrixClient.roomMembershipObserver
}
@Provides
fun providesRoomListService(matrixClient: MatrixClient): RoomListService {
return matrixClient.roomListService
}
@Provides
fun providesSyncService(matrixClient: MatrixClient): SyncService {
return matrixClient.syncService
}
@Provides
fun providesEncryptionService(matrixClient: MatrixClient): EncryptionService {
return matrixClient.encryptionService
}
@Provides
fun providesMatrixMediaLoader(matrixClient: MatrixClient): MatrixMediaLoader {
return matrixClient.matrixMediaLoader
}
@SessionCoroutineScope
@Provides
fun providesSessionCoroutineScope(matrixClient: MatrixClient): CoroutineScope {
return matrixClient.sessionCoroutineScope
}
@Provides
fun providesRoomDirectoryService(matrixClient: MatrixClient): RoomDirectoryService {
return matrixClient.roomDirectoryService
}
@Provides
fun providesMediaPreviewService(matrixClient: MatrixClient): MediaPreviewService {
return matrixClient.mediaPreviewService
}
@Provides
fun providesSpaceService(matrixClient: MatrixClient): SpaceService {
return matrixClient.spaceService
}
}
@@ -0,0 +1,26 @@
/*
* 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.impl.encryption
import io.element.android.libraries.matrix.api.encryption.BackupState
import org.matrix.rustcomponents.sdk.BackupState as RustBackupState
class BackupStateMapper {
fun map(backupState: RustBackupState): BackupState {
return when (backupState) {
RustBackupState.UNKNOWN -> BackupState.UNKNOWN
RustBackupState.CREATING -> BackupState.CREATING
RustBackupState.ENABLING -> BackupState.ENABLING
RustBackupState.RESUMING -> BackupState.RESUMING
RustBackupState.ENABLED -> BackupState.ENABLED
RustBackupState.DOWNLOADING -> BackupState.DOWNLOADING
RustBackupState.DISABLING -> BackupState.DISABLING
}
}
}
@@ -0,0 +1,39 @@
/*
* 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.impl.encryption
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
import org.matrix.rustcomponents.sdk.BackupUploadState as RustBackupUploadState
class BackupUploadStateMapper {
fun map(rustEnableProgress: RustBackupUploadState): BackupUploadState {
return when (rustEnableProgress) {
RustBackupUploadState.Done ->
BackupUploadState.Done
is RustBackupUploadState.Uploading -> {
val backedUpCount = rustEnableProgress.backedUpCount.toInt()
val totalCount = rustEnableProgress.totalCount.toInt()
if (backedUpCount == totalCount) {
// Consider that the state is Done in this case,
// the SDK will not send a Done state
BackupUploadState.Done
} else {
BackupUploadState.Uploading(
backedUpCount = backedUpCount,
totalCount = totalCount,
)
}
}
RustBackupUploadState.Waiting ->
BackupUploadState.Waiting
RustBackupUploadState.Error ->
BackupUploadState.Error
}
}
}
@@ -0,0 +1,30 @@
/*
* 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.impl.encryption
import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
import org.matrix.rustcomponents.sdk.EnableRecoveryProgress as RustEnableRecoveryProgress
class EnableRecoveryProgressMapper {
fun map(rustEnableProgress: RustEnableRecoveryProgress): EnableRecoveryProgress {
return when (rustEnableProgress) {
is RustEnableRecoveryProgress.Starting -> EnableRecoveryProgress.Starting
is RustEnableRecoveryProgress.CreatingBackup -> EnableRecoveryProgress.CreatingBackup
is RustEnableRecoveryProgress.CreatingRecoveryKey -> EnableRecoveryProgress.CreatingRecoveryKey
is RustEnableRecoveryProgress.BackingUp -> EnableRecoveryProgress.BackingUp(
backedUpCount = rustEnableProgress.backedUpCount.toInt(),
totalCount = rustEnableProgress.totalCount.toInt(),
)
is RustEnableRecoveryProgress.RoomKeyUploadError -> EnableRecoveryProgress.RoomKeyUploadError
is RustEnableRecoveryProgress.Done -> EnableRecoveryProgress.Done(
recoveryKey = rustEnableProgress.recoveryKey
)
}
}
}
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.encryption
import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import kotlinx.coroutines.flow.Flow
import org.matrix.rustcomponents.sdk.BackupStateListener
import org.matrix.rustcomponents.sdk.EncryptionInterface
import org.matrix.rustcomponents.sdk.RecoveryStateListener
import org.matrix.rustcomponents.sdk.BackupState as RustBackupState
import org.matrix.rustcomponents.sdk.RecoveryState as RustRecoveryState
internal fun EncryptionInterface.backupStateFlow(): Flow<BackupState> = mxCallbackFlow {
val backupStateMapper = BackupStateMapper()
trySend(backupStateMapper.map(backupState()))
val listener = object : BackupStateListener {
override fun onUpdate(status: RustBackupState) {
trySend(backupStateMapper.map(status))
}
}
backupStateListener(listener)
}
internal fun EncryptionInterface.recoveryStateFlow(): Flow<RecoveryState> = mxCallbackFlow {
val recoveryStateMapper = RecoveryStateMapper()
trySend(recoveryStateMapper.map(recoveryState()))
val listener = object : RecoveryStateListener {
override fun onUpdate(status: RustRecoveryState) {
trySend(recoveryStateMapper.map(status))
}
}
recoveryStateListener(listener)
}
@@ -0,0 +1,36 @@
/*
* 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.impl.encryption
import io.element.android.libraries.matrix.api.encryption.RecoveryException
import io.element.android.libraries.matrix.api.exception.ClientException
import io.element.android.libraries.matrix.impl.exception.mapClientException
import org.matrix.rustcomponents.sdk.RecoveryException as RustRecoveryException
fun Throwable.mapRecoveryException(): RecoveryException {
return when (this) {
is RustRecoveryException -> {
when (this) {
is RustRecoveryException.SecretStorage -> RecoveryException.SecretStorage(
message = errorMessage
)
is RustRecoveryException.BackupExistsOnServer -> RecoveryException.BackupExistsOnServer
is RustRecoveryException.Import -> RecoveryException.Import(
message = errorMessage
)
is RustRecoveryException.Client -> RecoveryException.Client(
source.mapClientException()
)
}
}
else -> RecoveryException.Client(
ClientException.Other("Unknown error")
)
}
}
@@ -0,0 +1,23 @@
/*
* 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.impl.encryption
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import org.matrix.rustcomponents.sdk.RecoveryState as RustRecoveryState
class RecoveryStateMapper {
fun map(state: RustRecoveryState): RecoveryState {
return when (state) {
RustRecoveryState.UNKNOWN -> RecoveryState.UNKNOWN
RustRecoveryState.ENABLED -> RecoveryState.ENABLED
RustRecoveryState.DISABLED -> RecoveryState.DISABLED
RustRecoveryState.INCOMPLETE -> RecoveryState.INCOMPLETE
}
}
}
@@ -0,0 +1,272 @@
/*
* 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.impl.encryption
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.extensions.runCatchingExceptions
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.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.IdentityResetHandle
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.impl.exception.mapClientException
import io.element.android.libraries.matrix.impl.sync.RustSyncService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.BackupSteadyStateListener
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.EnableRecoveryProgressListener
import org.matrix.rustcomponents.sdk.Encryption
import org.matrix.rustcomponents.sdk.UserIdentity
import timber.log.Timber
import org.matrix.rustcomponents.sdk.BackupUploadState as RustBackupUploadState
import org.matrix.rustcomponents.sdk.EnableRecoveryProgress as RustEnableRecoveryProgress
import org.matrix.rustcomponents.sdk.RecoveryException as RustRecoveryException
import org.matrix.rustcomponents.sdk.SteadyStateException as RustSteadyStateException
class RustEncryptionService(
client: Client,
syncService: RustSyncService,
sessionCoroutineScope: CoroutineScope,
private val dispatchers: CoroutineDispatchers,
) : EncryptionService {
private val service: Encryption = client.encryption()
private val sessionId = SessionId(client.session().userId)
private val enableRecoveryProgressMapper = EnableRecoveryProgressMapper()
private val backupUploadStateMapper = BackupUploadStateMapper()
private val steadyStateExceptionMapper = SteadyStateExceptionMapper()
override val backupStateStateFlow = combine(
service.backupStateFlow(),
syncService.syncState,
) { backupState, syncState ->
if (syncState == SyncState.Running) {
backupState
} else {
BackupState.WAITING_FOR_SYNC
}
}.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, BackupState.WAITING_FOR_SYNC)
override val recoveryStateStateFlow = combine(
service.recoveryStateFlow(),
syncService.syncState,
) { recoveryState, syncState ->
if (syncState == SyncState.Running) {
recoveryState
} else {
RecoveryState.WAITING_FOR_SYNC
}
}.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, RecoveryState.WAITING_FOR_SYNC)
override val enableRecoveryProgressStateFlow: MutableStateFlow<EnableRecoveryProgress> = MutableStateFlow(EnableRecoveryProgress.Starting)
/**
* Check if the session is the last session every 5 seconds.
* TODO This is a temporary workaround, when we will have a way to observe
* the sessions, this code will have to be updated.
*/
override val isLastDevice: StateFlow<Boolean> = flow {
while (currentCoroutineContext().isActive) {
val result = isLastDevice().getOrDefault(false)
emit(result)
delay(5_000)
}
}
.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, false)
/**
* Check if the user has any devices available to verify against every 5 seconds.
* TODO This is a temporary workaround, when we will have a way to observe
* the sessions, this code will have to be updated.
*/
override val hasDevicesToVerifyAgainst: StateFlow<AsyncData<Boolean>> = flow {
while (currentCoroutineContext().isActive) {
val result = hasDevicesToVerifyAgainst()
result
.onSuccess {
emit(AsyncData.Success(it))
}
.onFailure {
Timber.e(it, "Failed to get hasDevicesToVerifyAgainst, retrying in 5s...")
}
delay(5_000)
}
}
.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, AsyncData.Uninitialized)
override suspend fun enableBackups(): Result<Unit> = withContext(dispatchers.io) {
runCatchingExceptions {
service.enableBackups()
}.mapFailure {
it.mapRecoveryException()
}
}
override suspend fun enableRecovery(
waitForBackupsToUpload: Boolean,
): Result<Unit> = withContext(dispatchers.io) {
runCatchingExceptions {
service.enableRecovery(
waitForBackupsToUpload = waitForBackupsToUpload,
progressListener = object : EnableRecoveryProgressListener {
override fun onUpdate(status: RustEnableRecoveryProgress) {
enableRecoveryProgressStateFlow.value = enableRecoveryProgressMapper.map(status)
}
},
passphrase = null,
)
// enableRecovery returns the encryption key, but we read it from the state flow
.let { }
}.mapFailure {
it.mapRecoveryException()
}
}
override suspend fun doesBackupExistOnServer(): Result<Boolean> = withContext(dispatchers.io) {
runCatchingExceptions {
service.backupExistsOnServer()
}
}
override fun waitForBackupUploadSteadyState(): Flow<BackupUploadState> {
return callbackFlow {
runCatchingExceptions {
service.waitForBackupUploadSteadyState(
progressListener = object : BackupSteadyStateListener {
override fun onUpdate(status: RustBackupUploadState) {
trySend(backupUploadStateMapper.map(status))
if (status == RustBackupUploadState.Done) {
close()
}
}
}
)
}.onFailure {
if (it is RustSteadyStateException) {
trySend(BackupUploadState.SteadyException(steadyStateExceptionMapper.map(it)))
} else {
trySend(BackupUploadState.Error)
}
close()
}
awaitClose {}
}
}
override suspend fun disableRecovery(): Result<Unit> = withContext(dispatchers.io) {
runCatchingExceptions {
service.disableRecovery()
}.mapFailure {
it.mapRecoveryException()
}
}
private suspend fun isLastDevice(): Result<Boolean> = withContext(dispatchers.io) {
runCatchingExceptions {
service.isLastDevice()
}.mapFailure {
it.mapRecoveryException()
}
}
private suspend fun hasDevicesToVerifyAgainst(): Result<Boolean> = withContext(dispatchers.io) {
runCatchingExceptions {
service.hasDevicesToVerifyAgainst()
}.mapFailure {
it.mapClientException()
}
}
override suspend fun resetRecoveryKey(): Result<String> = withContext(dispatchers.io) {
runCatchingExceptions {
service.resetRecoveryKey()
}.mapFailure {
it.mapRecoveryException()
}
}
override suspend fun recover(recoveryKey: String): Result<Unit> = withContext(dispatchers.io) {
runCatchingExceptions {
service.recover(recoveryKey)
}.recoverCatching {
when (it) {
// We ignore import errors because the user will be notified about them via the "Key storage out of sync" detection.
is RustRecoveryException.Import -> Unit
else -> throw it.mapRecoveryException()
}
}
}
override suspend fun deviceCurve25519(): String? {
return runCatchingExceptions { service.curve25519Key() }.getOrNull()
}
override suspend fun deviceEd25519(): String? {
return runCatchingExceptions { service.ed25519Key() }.getOrNull()
}
override suspend fun startIdentityReset(): Result<IdentityResetHandle?> {
return runCatchingExceptions {
service.resetIdentity()
}.flatMap { handle ->
RustIdentityResetHandleFactory.create(sessionId, handle)
}
}
override suspend fun pinUserIdentity(userId: UserId): Result<Unit> = runCatchingExceptions {
getUserIdentityInternal(userId).pin()
}
override suspend fun withdrawVerification(userId: UserId): Result<Unit> = runCatchingExceptions {
getUserIdentityInternal(userId).withdrawVerification()
}
override suspend fun getUserIdentity(userId: UserId, fallbackToServer: Boolean): Result<IdentityState?> = runCatchingExceptions {
val identity = getUserIdentityInternal(userId, fallbackToServer)
val isVerified = identity.isVerified()
when {
identity.hasVerificationViolation() -> IdentityState.VerificationViolation
isVerified -> IdentityState.Verified
!isVerified -> IdentityState.Pinned
else -> null
}
}
private suspend fun getUserIdentityInternal(userId: UserId, fallbackToServer: Boolean = true): UserIdentity {
return service.userIdentity(
userId = userId.value,
fallbackToServer = fallbackToServer,
) ?: error("User identity not found")
}
fun close() {
service.close()
}
}
@@ -0,0 +1,66 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.encryption
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.IdentityOidcResetHandle
import io.element.android.libraries.matrix.api.encryption.IdentityPasswordResetHandle
import io.element.android.libraries.matrix.api.encryption.IdentityResetHandle
import org.matrix.rustcomponents.sdk.AuthData
import org.matrix.rustcomponents.sdk.AuthDataPasswordDetails
import org.matrix.rustcomponents.sdk.CrossSigningResetAuthType
object RustIdentityResetHandleFactory {
fun create(
userId: UserId,
identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle?
): Result<IdentityResetHandle?> {
return runCatchingExceptions {
identityResetHandle?.let {
when (val authType = identityResetHandle.authType()) {
is CrossSigningResetAuthType.Oidc -> RustOidcIdentityResetHandle(identityResetHandle, authType.info.approvalUrl)
// User interactive authentication (user + password)
CrossSigningResetAuthType.Uiaa -> RustPasswordIdentityResetHandle(userId, identityResetHandle)
}
}
}
}
}
class RustPasswordIdentityResetHandle(
private val userId: UserId,
private val identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle,
) : IdentityPasswordResetHandle {
override suspend fun resetPassword(password: String): Result<Unit> {
return runCatchingExceptions { identityResetHandle.reset(AuthData.Password(AuthDataPasswordDetails(userId.value, password))) }
}
override suspend fun cancel() {
identityResetHandle.cancelAndDestroy()
}
}
class RustOidcIdentityResetHandle(
private val identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle,
override val url: String,
) : IdentityOidcResetHandle {
override suspend fun resetOidc(): Result<Unit> {
return runCatchingExceptions { identityResetHandle.reset(null) }
}
override suspend fun cancel() {
identityResetHandle.cancelAndDestroy()
}
}
private suspend fun org.matrix.rustcomponents.sdk.IdentityResetHandle.cancelAndDestroy() {
cancel()
destroy()
}
@@ -0,0 +1,28 @@
/*
* 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.impl.encryption
import io.element.android.libraries.matrix.api.encryption.SteadyStateException
import org.matrix.rustcomponents.sdk.SteadyStateException as RustSteadyStateException
class SteadyStateExceptionMapper {
fun map(data: RustSteadyStateException): SteadyStateException {
return when (data) {
is RustSteadyStateException.BackupDisabled -> SteadyStateException.BackupDisabled(
message = data.message.orEmpty()
)
is RustSteadyStateException.Connection -> SteadyStateException.Connection(
message = data.message.orEmpty()
)
is RustSteadyStateException.Lagged -> SteadyStateException.Lagged(
message = data.message.orEmpty()
)
}
}
}
@@ -0,0 +1,29 @@
/*
* 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.impl.exception
import io.element.android.libraries.matrix.api.exception.ClientException
import org.matrix.rustcomponents.sdk.ClientException as RustClientException
fun Throwable.mapClientException(): ClientException {
return when (this) {
is RustClientException -> {
when (this) {
is RustClientException.Generic -> ClientException.Generic(msg, details)
is RustClientException.MatrixApi -> ClientException.MatrixApi(
kind = kind.map(),
code = code,
message = msg,
details = details,
)
}
}
else -> ClientException.Other(message ?: "Unknown error")
}
}
@@ -0,0 +1,64 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.impl.exception
import io.element.android.libraries.matrix.api.exception.ErrorKind
import org.matrix.rustcomponents.sdk.ErrorKind as RustErrorKind
fun RustErrorKind.map(): ErrorKind {
return when (this) {
RustErrorKind.BadAlias -> ErrorKind.BadAlias
RustErrorKind.BadJson -> ErrorKind.BadJson
RustErrorKind.BadState -> ErrorKind.BadState
is RustErrorKind.BadStatus -> ErrorKind.BadStatus(status?.toInt(), body)
RustErrorKind.CannotLeaveServerNoticeRoom -> ErrorKind.CannotLeaveServerNoticeRoom
RustErrorKind.CannotOverwriteMedia -> ErrorKind.CannotOverwriteMedia
RustErrorKind.CaptchaInvalid -> ErrorKind.CaptchaInvalid
RustErrorKind.CaptchaNeeded -> ErrorKind.CaptchaNeeded
RustErrorKind.ConnectionFailed -> ErrorKind.ConnectionFailed
RustErrorKind.ConnectionTimeout -> ErrorKind.ConnectionTimeout
is RustErrorKind.Custom -> ErrorKind.Custom(errcode)
RustErrorKind.DuplicateAnnotation -> ErrorKind.DuplicateAnnotation
RustErrorKind.Exclusive -> ErrorKind.Exclusive
RustErrorKind.Forbidden -> ErrorKind.Forbidden
RustErrorKind.GuestAccessForbidden -> ErrorKind.GuestAccessForbidden
is RustErrorKind.IncompatibleRoomVersion -> ErrorKind.IncompatibleRoomVersion(roomVersion)
RustErrorKind.InvalidParam -> ErrorKind.InvalidParam
RustErrorKind.InvalidRoomState -> ErrorKind.InvalidRoomState
RustErrorKind.InvalidUsername -> ErrorKind.InvalidUsername
is RustErrorKind.LimitExceeded -> ErrorKind.LimitExceeded(retryAfterMs?.toLong())
RustErrorKind.MissingParam -> ErrorKind.MissingParam
RustErrorKind.MissingToken -> ErrorKind.MissingToken
RustErrorKind.NotFound -> ErrorKind.NotFound
RustErrorKind.NotJson -> ErrorKind.NotJson
RustErrorKind.NotYetUploaded -> ErrorKind.NotYetUploaded
is RustErrorKind.ResourceLimitExceeded -> ErrorKind.ResourceLimitExceeded(adminContact)
RustErrorKind.RoomInUse -> ErrorKind.RoomInUse
RustErrorKind.ServerNotTrusted -> ErrorKind.ServerNotTrusted
RustErrorKind.ThreepidAuthFailed -> ErrorKind.ThreepidAuthFailed
RustErrorKind.ThreepidDenied -> ErrorKind.ThreepidDenied
RustErrorKind.ThreepidInUse -> ErrorKind.ThreepidInUse
RustErrorKind.ThreepidMediumNotSupported -> ErrorKind.ThreepidMediumNotSupported
RustErrorKind.ThreepidNotFound -> ErrorKind.ThreepidNotFound
RustErrorKind.TooLarge -> ErrorKind.TooLarge
RustErrorKind.UnableToAuthorizeJoin -> ErrorKind.UnableToAuthorizeJoin
RustErrorKind.UnableToGrantJoin -> ErrorKind.UnableToGrantJoin
RustErrorKind.Unauthorized -> ErrorKind.Unauthorized
RustErrorKind.Unknown -> ErrorKind.Unknown
is RustErrorKind.UnknownToken -> ErrorKind.UnknownToken(softLogout)
RustErrorKind.Unrecognized -> ErrorKind.Unrecognized
RustErrorKind.UnsupportedRoomVersion -> ErrorKind.UnsupportedRoomVersion
RustErrorKind.UrlNotSet -> ErrorKind.UrlNotSet
RustErrorKind.UserDeactivated -> ErrorKind.UserDeactivated
RustErrorKind.UserInUse -> ErrorKind.UserInUse
RustErrorKind.UserLocked -> ErrorKind.UserLocked
RustErrorKind.UserSuspended -> ErrorKind.UserSuspended
RustErrorKind.WeakPassword -> ErrorKind.WeakPassword
is RustErrorKind.WrongRoomKeysVersion -> ErrorKind.WrongRoomKeysVersion(currentVersion)
}
}
@@ -0,0 +1,25 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.keys
import android.util.Base64
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import java.security.SecureRandom
private const val SECRET_SIZE = 256
@ContributesBinding(AppScope::class)
class DefaultPassphraseGenerator : PassphraseGenerator {
override fun generatePassphrase(): String? {
val key = ByteArray(size = SECRET_SIZE)
SecureRandom().nextBytes(key)
return Base64.encodeToString(key, Base64.NO_PADDING or Base64.NO_WRAP)
}
}
@@ -0,0 +1,17 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.keys
interface PassphraseGenerator {
/**
* Generate a passphrase to encrypt the databases of a session.
* Return null to not encrypt the databases.
*/
fun generatePassphrase(): String?
}
@@ -0,0 +1,19 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.mapper
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import uniffi.matrix_sdk_crypto.IdentityState as RustIdentityState
fun RustIdentityState.map(): IdentityState = when (this) {
RustIdentityState.VERIFIED -> IdentityState.Verified
RustIdentityState.PINNED -> IdentityState.Pinned
RustIdentityState.PIN_VIOLATION -> IdentityState.PinViolation
RustIdentityState.VERIFICATION_VIOLATION -> IdentityState.VerificationViolation
}
@@ -0,0 +1,66 @@
/*
* 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.impl.mapper
import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import io.element.android.libraries.matrix.impl.paths.SessionPaths
import io.element.android.libraries.sessionstorage.api.LoginType
import io.element.android.libraries.sessionstorage.api.SessionData
import org.matrix.rustcomponents.sdk.Session
import java.util.Date
internal fun Session.toSessionData(
isTokenValid: Boolean,
loginType: LoginType,
passphrase: String?,
sessionPaths: SessionPaths,
homeserverUrl: String? = null,
) = SessionData(
userId = userId,
deviceId = deviceId,
accessToken = accessToken,
refreshToken = refreshToken,
homeserverUrl = homeserverUrl ?: this.homeserverUrl,
oidcData = oidcData,
loginTimestamp = Date(),
isTokenValid = isTokenValid,
loginType = loginType,
passphrase = passphrase,
sessionPath = sessionPaths.fileDirectory.absolutePath,
cachePath = sessionPaths.cacheDirectory.absolutePath,
// Note: position and lastUsageIndex will be set by the SessionStore when adding the session
position = 0,
lastUsageIndex = 0,
userDisplayName = null,
userAvatarUrl = null,
)
internal fun ExternalSession.toSessionData(
isTokenValid: Boolean,
loginType: LoginType,
passphrase: String?,
sessionPaths: SessionPaths,
) = SessionData(
userId = userId,
deviceId = deviceId,
accessToken = accessToken,
refreshToken = refreshToken,
homeserverUrl = homeserverUrl,
oidcData = null,
loginTimestamp = Date(),
isTokenValid = isTokenValid,
loginType = loginType,
passphrase = passphrase,
sessionPath = sessionPaths.fileDirectory.absolutePath,
cachePath = sessionPaths.cacheDirectory.absolutePath,
position = 0,
lastUsageIndex = 0,
userDisplayName = null,
userAvatarUrl = null,
)
@@ -0,0 +1,19 @@
/*
* 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.impl.mapper
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import org.matrix.rustcomponents.sdk.UserProfile
fun UserProfile.map() = MatrixUser(
userId = UserId(userId),
displayName = displayName,
avatarUrl = avatarUrl,
)
@@ -0,0 +1,41 @@
/*
* 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.impl.media
import io.element.android.libraries.matrix.api.media.AudioDetails
import kotlinx.collections.immutable.toImmutableList
import kotlin.time.toJavaDuration
import kotlin.time.toKotlinDuration
import org.matrix.rustcomponents.sdk.UnstableAudioDetailsContent as RustAudioDetails
fun RustAudioDetails.map(): AudioDetails = AudioDetails(
duration = duration.toKotlinDuration(),
waveform = waveform.fromMSC3246range().toImmutableList(),
)
fun AudioDetails.map(): RustAudioDetails = RustAudioDetails(
duration = duration.toJavaDuration(),
waveform = waveform.toMSC3246range()
)
/**
* Resizes the given [0;1024] int list as per unstable MSC3246 spec
* to a [0;1] float list to be used for waveform rendering.
*
* https://github.com/matrix-org/matrix-spec-proposals/blob/travis/msc/audio-waveform/proposals/3246-audio-waveform.md
*/
internal fun List<UShort>.fromMSC3246range(): List<Float> = map { it.toInt() / 1024f }
/**
* Resizes the given [0;1] float list as per unstable MSC3246 spec
* to a [0;1024] int list to be used for waveform rendering.
*
* https://github.com/matrix-org/matrix-spec-proposals/blob/travis/msc/audio-waveform/proposals/3246-audio-waveform.md
*/
internal fun List<Float>.toMSC3246range(): List<UShort> = map { (it * 1024).toInt().toUShort() }
@@ -0,0 +1,26 @@
/*
* 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.impl.media
import io.element.android.libraries.matrix.api.media.AudioInfo
import kotlin.time.toJavaDuration
import kotlin.time.toKotlinDuration
import org.matrix.rustcomponents.sdk.AudioInfo as RustAudioInfo
fun RustAudioInfo.map(): AudioInfo = AudioInfo(
duration = duration?.toKotlinDuration(),
size = size?.toLong(),
mimetype = mimetype
)
fun AudioInfo.map(): RustAudioInfo = RustAudioInfo(
duration = duration?.toJavaDuration(),
size = size?.toULong(),
mimetype = mimetype,
)
@@ -0,0 +1,26 @@
/*
* 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.impl.media
import io.element.android.libraries.matrix.api.media.FileInfo
import org.matrix.rustcomponents.sdk.FileInfo as RustFileInfo
fun RustFileInfo.map(): FileInfo = FileInfo(
mimetype = mimetype,
size = size?.toLong(),
thumbnailInfo = thumbnailInfo?.map(),
thumbnailSource = thumbnailSource?.map()
)
fun FileInfo.map(): RustFileInfo = RustFileInfo(
mimetype = mimetype,
size = size?.toULong(),
thumbnailInfo = thumbnailInfo?.map(),
thumbnailSource = null
)
@@ -0,0 +1,33 @@
/*
* 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.impl.media
import io.element.android.libraries.matrix.api.media.ImageInfo
import org.matrix.rustcomponents.sdk.ImageInfo as RustImageInfo
fun RustImageInfo.map(): ImageInfo = ImageInfo(
height = height?.toLong(),
width = width?.toLong(),
mimetype = mimetype,
size = size?.toLong(),
thumbnailInfo = thumbnailInfo?.map(),
thumbnailSource = thumbnailSource?.map(),
blurhash = blurhash
)
fun ImageInfo.map(): RustImageInfo = RustImageInfo(
height = height?.toULong(),
width = width?.toULong(),
mimetype = mimetype,
size = size?.toULong(),
thumbnailInfo = thumbnailInfo?.map(),
thumbnailSource = null,
blurhash = blurhash,
isAnimated = null
)
@@ -0,0 +1,17 @@
/*
* 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.impl.media
import io.element.android.libraries.matrix.api.media.MediaSource
import org.matrix.rustcomponents.sdk.use
import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource
fun RustMediaSource.map(): MediaSource = use {
MediaSource(it.url(), it.toJson())
}
@@ -0,0 +1,35 @@
/*
* 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.impl.media
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
import java.io.File
class MediaUploadHandlerImpl(
private val filesToUpload: List<File>,
private val sendAttachmentJoinHandle: SendAttachmentJoinHandle,
) : MediaUploadHandler {
override suspend fun await(): Result<Unit> =
runCatchingExceptions {
sendAttachmentJoinHandle.join()
}
.also { cleanUpFiles() }
override fun cancel() {
sendAttachmentJoinHandle.cancel()
cleanUpFiles()
}
private fun cleanUpFiles() {
filesToUpload.forEach { file -> file.safeDelete() }
}
}
@@ -0,0 +1,26 @@
/*
* 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.impl.media
import io.element.android.libraries.matrix.api.media.MediaFile
import org.matrix.rustcomponents.sdk.MediaFileHandle
class RustMediaFile(private val inner: MediaFileHandle) : MediaFile {
override fun path(): String {
return inner.path()
}
override fun persist(path: String): Boolean {
return inner.persist(path)
}
override fun close() {
inner.close()
}
}
@@ -0,0 +1,97 @@
/*
* 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.impl.media
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaFile
import io.element.android.libraries.matrix.api.media.MediaSource
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.use
import java.io.File
import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource
class RustMediaLoader(
private val baseCacheDirectory: File,
dispatchers: CoroutineDispatchers,
private val innerClient: Client,
) : MatrixMediaLoader {
private val mediaDispatcher = dispatchers.io.limitedParallelism(32)
private val cacheDirectory
get() = File(baseCacheDirectory, "temp/media").apply {
if (!exists()) mkdirs() // Must always ensure that this directory exists because "Clear cache" does not restart an app's process.
}
override suspend fun loadMediaContent(source: MediaSource): Result<ByteArray> =
withContext(mediaDispatcher) {
runCatchingExceptions {
source.toRustMediaSource().use { source ->
innerClient.getMediaContent(source)
}
}
}
override suspend fun loadMediaThumbnail(
source: MediaSource,
width: Long,
height: Long
): Result<ByteArray> =
withContext(mediaDispatcher) {
runCatchingExceptions {
source.toRustMediaSource().use { mediaSource ->
innerClient.getMediaThumbnail(
mediaSource = mediaSource,
width = width.toULong(),
height = height.toULong()
)
}
}
}
override suspend fun downloadMediaFile(
source: MediaSource,
mimeType: String?,
filename: String?,
useCache: Boolean,
): Result<MediaFile> =
withContext(mediaDispatcher) {
runCatchingExceptions {
source.toRustMediaSource().use { mediaSource ->
val mediaFile = innerClient.getMediaFile(
mediaSource = mediaSource,
filename = filename,
mimeType = when {
mimeType == null -> MimeTypes.OctetStream
MimeTypes.hasSubtype(mimeType) -> mimeType
// Fallback to a default mime type based on the main type, so that the SDK can create a file with the correct extension.
mimeType == MimeTypes.Images -> MimeTypes.Jpeg
mimeType == MimeTypes.Videos -> MimeTypes.Mp4
mimeType == MimeTypes.Audio -> MimeTypes.Mp3
else -> MimeTypes.OctetStream
},
useCache = useCache,
tempDir = cacheDirectory.path,
)
RustMediaFile(mediaFile)
}
}
}
private fun MediaSource.toRustMediaSource(): RustMediaSource {
val json = this.json
return if (json != null) {
RustMediaSource.fromJson(json)
} else {
RustMediaSource.fromUrl(url)
}
}
}
@@ -0,0 +1,90 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.impl.media
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.media.MediaPreviewConfig
import io.element.android.libraries.matrix.api.media.MediaPreviewService
import io.element.android.libraries.matrix.api.media.MediaPreviewValue
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.InviteAvatars
import org.matrix.rustcomponents.sdk.MediaPreviewConfigListener
import org.matrix.rustcomponents.sdk.MediaPreviews
import org.matrix.rustcomponents.sdk.MediaPreviewConfig as RustMediaPreviewConfig
class RustMediaPreviewService(
sessionCoroutineScope: CoroutineScope,
private val sessionDispatcher: CoroutineDispatcher,
private val innerClient: Client,
) : MediaPreviewService {
override val mediaPreviewConfigFlow: StateFlow<MediaPreviewConfig> =
innerClient
.getMediaPreviewConfigFlow()
.stateIn(sessionCoroutineScope, started = SharingStarted.Lazily, initialValue = MediaPreviewConfig.DEFAULT)
override suspend fun fetchMediaPreviewConfig(): Result<MediaPreviewConfig?> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.fetchMediaPreviewConfig()?.into()
}
}
override suspend fun setMediaPreviewValue(mediaPreviewValue: MediaPreviewValue): Result<Unit> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.setMediaPreviewDisplayPolicy(mediaPreviewValue.into())
}
}
override suspend fun setHideInviteAvatars(hide: Boolean): Result<Unit> = withContext(sessionDispatcher) {
runCatchingExceptions {
val inviteAvatars = if (hide) InviteAvatars.OFF else InviteAvatars.ON
innerClient.setInviteAvatarsDisplayPolicy(inviteAvatars)
}
}
}
private fun RustMediaPreviewConfig.into(): MediaPreviewConfig {
return MediaPreviewConfig(
mediaPreviewValue = mediaPreviews.into(),
hideInviteAvatar = inviteAvatars == InviteAvatars.OFF
)
}
private fun Client.getMediaPreviewConfigFlow() = mxCallbackFlow {
subscribeToMediaPreviewConfig(object : MediaPreviewConfigListener {
override fun onChange(mediaPreviewConfig: RustMediaPreviewConfig?) {
if (mediaPreviewConfig != null) {
trySend(mediaPreviewConfig.into())
}
}
})
}
private fun MediaPreviewValue.into(): MediaPreviews {
return when (this) {
MediaPreviewValue.On -> MediaPreviews.ON
MediaPreviewValue.Off -> MediaPreviews.OFF
MediaPreviewValue.Private -> MediaPreviews.PRIVATE
}
}
private fun MediaPreviews?.into(): MediaPreviewValue {
return when (this) {
null -> MediaPreviewValue.DEFAULT
MediaPreviews.ON -> MediaPreviewValue.On
MediaPreviews.OFF -> MediaPreviewValue.Off
MediaPreviews.PRIVATE -> MediaPreviewValue.Private
}
}
@@ -0,0 +1,26 @@
/*
* 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.impl.media
import io.element.android.libraries.matrix.api.media.ThumbnailInfo
import org.matrix.rustcomponents.sdk.ThumbnailInfo as RustThumbnailInfo
fun RustThumbnailInfo.map(): ThumbnailInfo = ThumbnailInfo(
height = height?.toLong(),
width = width?.toLong(),
mimetype = mimetype,
size = size?.toLong()
)
fun ThumbnailInfo.map(): RustThumbnailInfo = RustThumbnailInfo(
height = height?.toULong(),
width = width?.toULong(),
mimetype = mimetype,
size = size?.toULong()
)
@@ -0,0 +1,36 @@
/*
* 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.impl.media
import io.element.android.libraries.matrix.api.media.VideoInfo
import kotlin.time.toJavaDuration
import kotlin.time.toKotlinDuration
import org.matrix.rustcomponents.sdk.VideoInfo as RustVideoInfo
fun RustVideoInfo.map(): VideoInfo = VideoInfo(
duration = duration?.toKotlinDuration(),
height = height?.toLong(),
width = width?.toLong(),
mimetype = mimetype,
size = size?.toLong(),
thumbnailInfo = thumbnailInfo?.map(),
thumbnailSource = thumbnailSource?.map(),
blurhash = blurhash
)
fun VideoInfo.map(): RustVideoInfo = RustVideoInfo(
duration = duration?.toJavaDuration(),
height = height?.toULong(),
width = width?.toULong(),
mimetype = mimetype,
size = size?.toULong(),
thumbnailInfo = thumbnailInfo?.map(),
thumbnailSource = null,
blurhash = blurhash
)
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2025 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.mxc
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.api.mxc.MxcTools
@ContributesBinding(AppScope::class)
class DefaultMxcTools : MxcTools {
/**
* Regex to match a Matrix Content (mxc://) URI.
*
* See: https://spec.matrix.org/v1.8/client-server-api/#matrix-content-mxc-uris
*/
private val mxcRegex = Regex("""^mxc://([^/]+)/([^/]+)$""")
/**
* Sanitizes an mxcUri to be used as a relative file path.
*
* @param mxcUri the Matrix Content (mxc://) URI of the file.
* @return the relative file path as "<server-name>/<media-id>" or null if the mxcUri is invalid.
*/
override fun mxcUri2FilePath(mxcUri: String): String? = mxcRegex.matchEntire(mxcUri)?.let { match ->
buildString {
append(match.groupValues[1])
append("/")
append(match.groupValues[2])
}
}
}
@@ -0,0 +1,83 @@
/*
* 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.impl.notification
import io.element.android.libraries.core.bool.orFalse
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.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.api.room.isDm
import io.element.android.services.toolbox.api.systemclock.SystemClock
import org.matrix.rustcomponents.sdk.NotificationEvent
import org.matrix.rustcomponents.sdk.NotificationItem
import org.matrix.rustcomponents.sdk.use
class NotificationMapper(
private val clock: SystemClock,
) {
private val notificationContentMapper = NotificationContentMapper()
fun map(
sessionId: SessionId,
eventId: EventId,
roomId: RoomId,
notificationItem: NotificationItem
): Result<NotificationData> {
return runCatchingExceptions {
notificationItem.use { item ->
val isDm = isDm(
isDirect = item.roomInfo.isDirect,
activeMembersCount = item.roomInfo.joinedMembersCount.toInt(),
)
val timestamp = item.timestamp() ?: clock.epochMillis()
NotificationData(
sessionId = sessionId,
eventId = eventId,
threadId = item.threadId?.let(::ThreadId),
roomId = roomId,
senderAvatarUrl = item.senderInfo.avatarUrl,
senderDisplayName = item.senderInfo.displayName,
senderIsNameAmbiguous = item.senderInfo.isNameAmbiguous,
roomAvatarUrl = item.roomInfo.avatarUrl ?: item.senderInfo.avatarUrl.takeIf { isDm },
roomDisplayName = item.roomInfo.displayName,
isDirect = item.roomInfo.isDirect,
isDm = isDm,
isEncrypted = item.roomInfo.isEncrypted.orFalse(),
isNoisy = item.isNoisy.orFalse(),
timestamp = timestamp,
content = notificationContentMapper.map(item.event).getOrThrow(),
hasMention = item.hasMention.orFalse(),
)
}
}
}
}
class NotificationContentMapper {
private val timelineEventToNotificationContentMapper = TimelineEventToNotificationContentMapper()
fun map(notificationEvent: NotificationEvent): Result<NotificationContent> =
when (notificationEvent) {
is NotificationEvent.Timeline -> timelineEventToNotificationContentMapper.map(notificationEvent.event)
is NotificationEvent.Invite -> Result.success(
NotificationContent.Invite(
senderId = UserId(notificationEvent.sender),
)
)
}
}
private fun NotificationItem.timestamp(): Long? {
return (this.event as? NotificationEvent.Timeline)?.event?.timestamp()?.toLong()
}
@@ -0,0 +1,95 @@
/*
* 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.impl.notification
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
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.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
import io.element.android.libraries.matrix.api.notification.GetNotificationDataResult
import io.element.android.libraries.matrix.api.notification.NotificationService
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.BatchNotificationResult
import org.matrix.rustcomponents.sdk.NotificationClient
import org.matrix.rustcomponents.sdk.NotificationItemsRequest
import org.matrix.rustcomponents.sdk.NotificationStatus
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
class RustNotificationService(
private val sessionId: SessionId,
private val notificationClient: NotificationClient,
private val dispatchers: CoroutineDispatchers,
clock: SystemClock,
) : NotificationService {
private val notificationMapper: NotificationMapper = NotificationMapper(clock)
override suspend fun getNotifications(
ids: Map<RoomId, List<EventId>>
): GetNotificationDataResult = withContext(dispatchers.io) {
runCatchingExceptions {
val requests = ids.map { (roomId, eventIds) ->
NotificationItemsRequest(
roomId = roomId.value,
eventIds = eventIds.map { it.value }
)
}
val items = notificationClient.getNotifications(requests)
buildMap {
val eventIds = requests.flatMap { it.eventIds }.distinct()
for (rawEventId in eventIds) {
val roomId = RoomId(requests.find { it.eventIds.contains(rawEventId) }?.roomId!!)
val eventId = EventId(rawEventId)
items[rawEventId].use { result ->
when (result) {
is BatchNotificationResult.Ok -> {
when (val status = result.status) {
is NotificationStatus.Event -> {
val result = notificationMapper.map(sessionId, eventId, roomId, status.item)
result.onFailure { Timber.e(it, "Could not map notification event $eventId") }
put(eventId, result)
}
is NotificationStatus.EventNotFound -> {
Timber.e("Could not retrieve event for notification with $eventId - event not found")
put(eventId, Result.failure(NotificationResolverException.EventNotFound))
}
is NotificationStatus.EventFilteredOut -> {
Timber.d("Could not retrieve event for notification with $eventId - event filtered out")
put(eventId, Result.failure(NotificationResolverException.EventFilteredOut))
}
}
}
is BatchNotificationResult.Error -> {
Timber.e("Error while retrieving notification with $rawEventId - ${result.message}")
put(
eventId,
Result.failure(NotificationResolverException.UnknownError(result.message))
)
}
null -> {
Timber.e("The notification data for $rawEventId was not in the retrieved results. This is unexpected.")
put(
eventId,
Result.failure(NotificationResolverException.UnknownError("Notification data not found"))
)
}
}
}
}
}
}
}
fun close() {
notificationClient.close()
}
}
@@ -0,0 +1,113 @@
/*
* 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.impl.notification
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.notification.NotificationContent
import io.element.android.libraries.matrix.api.notification.RtcNotificationType
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
import io.element.android.libraries.matrix.impl.timeline.item.event.EventMessageMapper
import org.matrix.rustcomponents.sdk.MessageLikeEventContent
import org.matrix.rustcomponents.sdk.StateEventContent
import org.matrix.rustcomponents.sdk.TimelineEvent
import org.matrix.rustcomponents.sdk.TimelineEventType
import org.matrix.rustcomponents.sdk.use
import org.matrix.rustcomponents.sdk.RtcNotificationType as SdkRtcNotificationType
class TimelineEventToNotificationContentMapper {
fun map(timelineEvent: TimelineEvent): Result<NotificationContent> {
return runCatchingExceptions {
timelineEvent.use {
val senderId = UserId(timelineEvent.senderId())
timelineEvent.eventType().use { eventType ->
eventType.toContent(senderId = senderId)
}
}
}
}
}
private fun TimelineEventType.toContent(senderId: UserId): NotificationContent {
return when (this) {
is TimelineEventType.MessageLike -> content.toContent(senderId)
is TimelineEventType.State -> content.toContent()
}
}
private fun StateEventContent.toContent(): NotificationContent.StateEvent {
return when (this) {
StateEventContent.PolicyRuleRoom -> NotificationContent.StateEvent.PolicyRuleRoom
StateEventContent.PolicyRuleServer -> NotificationContent.StateEvent.PolicyRuleServer
StateEventContent.PolicyRuleUser -> NotificationContent.StateEvent.PolicyRuleUser
StateEventContent.RoomAliases -> NotificationContent.StateEvent.RoomAliases
StateEventContent.RoomAvatar -> NotificationContent.StateEvent.RoomAvatar
StateEventContent.RoomCanonicalAlias -> NotificationContent.StateEvent.RoomCanonicalAlias
StateEventContent.RoomCreate -> NotificationContent.StateEvent.RoomCreate
StateEventContent.RoomEncryption -> NotificationContent.StateEvent.RoomEncryption
StateEventContent.RoomGuestAccess -> NotificationContent.StateEvent.RoomGuestAccess
StateEventContent.RoomHistoryVisibility -> NotificationContent.StateEvent.RoomHistoryVisibility
StateEventContent.RoomJoinRules -> NotificationContent.StateEvent.RoomJoinRules
is StateEventContent.RoomMemberContent -> {
NotificationContent.StateEvent.RoomMemberContent(
userId = UserId(userId),
membershipState = RoomMemberMapper.mapMembership(membershipState),
)
}
StateEventContent.RoomName -> NotificationContent.StateEvent.RoomName
StateEventContent.RoomPinnedEvents -> NotificationContent.StateEvent.RoomPinnedEvents
StateEventContent.RoomPowerLevels -> NotificationContent.StateEvent.RoomPowerLevels
StateEventContent.RoomServerAcl -> NotificationContent.StateEvent.RoomServerAcl
StateEventContent.RoomThirdPartyInvite -> NotificationContent.StateEvent.RoomThirdPartyInvite
StateEventContent.RoomTombstone -> NotificationContent.StateEvent.RoomTombstone
is StateEventContent.RoomTopic -> NotificationContent.StateEvent.RoomTopic(topic)
StateEventContent.SpaceChild -> NotificationContent.StateEvent.SpaceChild
StateEventContent.SpaceParent -> NotificationContent.StateEvent.SpaceParent
}
}
private fun MessageLikeEventContent.toContent(senderId: UserId): NotificationContent.MessageLike {
return use {
when (this) {
MessageLikeEventContent.CallAnswer -> NotificationContent.MessageLike.CallAnswer
MessageLikeEventContent.CallCandidates -> NotificationContent.MessageLike.CallCandidates
MessageLikeEventContent.CallHangup -> NotificationContent.MessageLike.CallHangup
MessageLikeEventContent.CallInvite -> NotificationContent.MessageLike.CallInvite(senderId)
is MessageLikeEventContent.RtcNotification -> NotificationContent.MessageLike.RtcNotification(
senderId = senderId,
type = notificationType.map(),
expirationTimestampMillis = expirationTs.toLong()
)
MessageLikeEventContent.KeyVerificationAccept -> NotificationContent.MessageLike.KeyVerificationAccept
MessageLikeEventContent.KeyVerificationCancel -> NotificationContent.MessageLike.KeyVerificationCancel
MessageLikeEventContent.KeyVerificationDone -> NotificationContent.MessageLike.KeyVerificationDone
MessageLikeEventContent.KeyVerificationKey -> NotificationContent.MessageLike.KeyVerificationKey
MessageLikeEventContent.KeyVerificationMac -> NotificationContent.MessageLike.KeyVerificationMac
MessageLikeEventContent.KeyVerificationReady -> NotificationContent.MessageLike.KeyVerificationReady
MessageLikeEventContent.KeyVerificationStart -> NotificationContent.MessageLike.KeyVerificationStart
is MessageLikeEventContent.ReactionContent -> NotificationContent.MessageLike.ReactionContent(relatedEventId)
MessageLikeEventContent.RoomEncrypted -> NotificationContent.MessageLike.RoomEncrypted
is MessageLikeEventContent.RoomMessage -> {
NotificationContent.MessageLike.RoomMessage(senderId, EventMessageMapper().mapMessageType(messageType))
}
is MessageLikeEventContent.RoomRedaction -> NotificationContent.MessageLike.RoomRedaction(
redactedEventId = redactedEventId?.let(::EventId),
reason = reason,
)
MessageLikeEventContent.Sticker -> NotificationContent.MessageLike.Sticker
is MessageLikeEventContent.Poll -> NotificationContent.MessageLike.Poll(senderId, question)
}
}
}
private fun SdkRtcNotificationType.map(): RtcNotificationType = when (this) {
SdkRtcNotificationType.NOTIFICATION -> RtcNotificationType.NOTIFY
SdkRtcNotificationType.RING -> RtcNotificationType.RING
}
@@ -0,0 +1,36 @@
/*
* 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.impl.notificationsettings
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
import org.matrix.rustcomponents.sdk.RoomNotificationMode as RustRoomNotificationMode
import org.matrix.rustcomponents.sdk.RoomNotificationSettings as RustRoomNotificationSettings
object RoomNotificationSettingsMapper {
fun map(roomNotificationSettings: RustRoomNotificationSettings): RoomNotificationSettings =
RoomNotificationSettings(
mode = mapMode(roomNotificationSettings.mode),
isDefault = roomNotificationSettings.isDefault
)
fun mapMode(mode: RustRoomNotificationMode): RoomNotificationMode =
when (mode) {
RustRoomNotificationMode.ALL_MESSAGES -> RoomNotificationMode.ALL_MESSAGES
RustRoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
RustRoomNotificationMode.MUTE -> RoomNotificationMode.MUTE
}
fun mapMode(mode: RoomNotificationMode): RustRoomNotificationMode =
when (mode) {
RoomNotificationMode.ALL_MESSAGES -> RustRoomNotificationMode.ALL_MESSAGES
RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> RustRoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
RoomNotificationMode.MUTE -> RustRoomNotificationMode.MUTE
}
}
@@ -0,0 +1,147 @@
/*
* 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.impl.notificationsettings
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.suspendLazy
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.NotificationSettingsDelegate
import org.matrix.rustcomponents.sdk.NotificationSettingsException
import timber.log.Timber
class RustNotificationSettingsService(
client: Client,
sessionCoroutineScope: CoroutineScope,
private val dispatchers: CoroutineDispatchers,
) : NotificationSettingsService {
private val notificationSettings by suspendLazy(sessionCoroutineScope.coroutineContext + dispatchers.io) { client.getNotificationSettings() }
private val _notificationSettingsChangeFlow = MutableSharedFlow<Unit>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
override val notificationSettingsChangeFlow: SharedFlow<Unit> = _notificationSettingsChangeFlow.asSharedFlow()
private var notificationSettingsDelegate = object : NotificationSettingsDelegate {
override fun settingsDidChange() {
_notificationSettingsChangeFlow.tryEmit(Unit)
}
}
suspend fun start() {
notificationSettings.await().setDelegate(notificationSettingsDelegate)
}
suspend fun destroy() {
notificationSettings.await().setDelegate(null)
}
override suspend fun getRoomNotificationSettings(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean): Result<RoomNotificationSettings> =
runCatchingExceptions {
notificationSettings.await().getRoomNotificationSettings(roomId.value, isEncrypted, isOneToOne).let(RoomNotificationSettingsMapper::map)
}
override suspend fun getDefaultRoomNotificationMode(isEncrypted: Boolean, isOneToOne: Boolean): Result<RoomNotificationMode> =
runCatchingExceptions {
notificationSettings.await().getDefaultRoomNotificationMode(isEncrypted, isOneToOne).let(RoomNotificationSettingsMapper::mapMode)
}
override suspend fun setDefaultRoomNotificationMode(
isEncrypted: Boolean,
mode: RoomNotificationMode,
isOneToOne: Boolean
): Result<Unit> = withContext(dispatchers.io) {
runCatchingExceptions {
try {
notificationSettings.await().setDefaultRoomNotificationMode(isEncrypted, isOneToOne, mode.let(RoomNotificationSettingsMapper::mapMode))
} catch (exception: NotificationSettingsException.RuleNotFound) {
// `setDefaultRoomNotificationMode` updates multiple rules including unstable rules (e.g. the polls push rules defined in the MSC3930)
// since production home servers may not have these rules yet, we drop the RuleNotFound error
Timber.w("Unable to find the rule: ${exception.ruleId}")
}
}
}
override suspend fun setRoomNotificationMode(roomId: RoomId, mode: RoomNotificationMode): Result<Unit> = withContext(dispatchers.io) {
runCatchingExceptions {
notificationSettings.await().setRoomNotificationMode(roomId.value, mode.let(RoomNotificationSettingsMapper::mapMode))
}
}
override suspend fun restoreDefaultRoomNotificationMode(roomId: RoomId): Result<Unit> = withContext(dispatchers.io) {
runCatchingExceptions {
notificationSettings.await().restoreDefaultRoomNotificationMode(roomId.value)
}
}
override suspend fun muteRoom(roomId: RoomId): Result<Unit> = setRoomNotificationMode(roomId, RoomNotificationMode.MUTE)
override suspend fun unmuteRoom(roomId: RoomId, isEncrypted: Boolean, isOneToOne: Boolean) = withContext(dispatchers.io) {
runCatchingExceptions {
notificationSettings.await().unmuteRoom(roomId.value, isEncrypted, isOneToOne)
}
}
override suspend fun isRoomMentionEnabled(): Result<Boolean> = withContext(dispatchers.io) {
runCatchingExceptions {
notificationSettings.await().isRoomMentionEnabled()
}
}
override suspend fun setRoomMentionEnabled(enabled: Boolean): Result<Unit> = withContext(dispatchers.io) {
runCatchingExceptions {
notificationSettings.await().setRoomMentionEnabled(enabled)
}
}
override suspend fun isCallEnabled(): Result<Boolean> = withContext(dispatchers.io) {
runCatchingExceptions {
notificationSettings.await().isCallEnabled()
}
}
override suspend fun setCallEnabled(enabled: Boolean): Result<Unit> = withContext(dispatchers.io) {
runCatchingExceptions {
notificationSettings.await().setCallEnabled(enabled)
}
}
override suspend fun isInviteForMeEnabled(): Result<Boolean> = withContext(dispatchers.io) {
runCatchingExceptions {
notificationSettings.await().isInviteForMeEnabled()
}
}
override suspend fun setInviteForMeEnabled(enabled: Boolean): Result<Unit> = withContext(dispatchers.io) {
runCatchingExceptions {
notificationSettings.await().setInviteForMeEnabled(enabled)
}
}
override suspend fun getRoomsWithUserDefinedRules(): Result<List<RoomId>> =
runCatchingExceptions {
notificationSettings.await().getRoomsWithUserDefinedRules(enabled = true).map(::RoomId)
}
override suspend fun canHomeServerPushEncryptedEventsToDevice(): Result<Boolean> =
runCatchingExceptions {
notificationSettings.await().canPushEncryptedEventToDevice()
}
override suspend fun getRawPushRules(): Result<String?> = runCatchingExceptions {
notificationSettings.await().getRawPushRules()
}
}
@@ -0,0 +1,21 @@
/*
* 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.impl.oidc
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
import org.matrix.rustcomponents.sdk.AccountManagementAction as RustAccountManagementAction
fun AccountManagementAction.toRustAction(): RustAccountManagementAction {
return when (this) {
AccountManagementAction.Profile -> RustAccountManagementAction.Profile
is AccountManagementAction.SessionEnd -> RustAccountManagementAction.SessionEnd(deviceId.value)
is AccountManagementAction.SessionView -> RustAccountManagementAction.SessionView(deviceId.value)
AccountManagementAction.SessionsList -> RustAccountManagementAction.SessionsList
}
}
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.paths
import io.element.android.libraries.sessionstorage.api.SessionData
import java.io.File
data class SessionPaths(
val fileDirectory: File,
val cacheDirectory: File,
) {
fun deleteRecursively() {
fileDirectory.deleteRecursively()
cacheDirectory.deleteRecursively()
}
}
internal fun SessionData.getSessionPaths(): SessionPaths {
return SessionPaths(
fileDirectory = File(sessionPath),
cacheDirectory = File(cachePath),
)
}
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.paths
import dev.zacsweers.metro.Inject
import io.element.android.libraries.di.BaseDirectory
import io.element.android.libraries.di.CacheDirectory
import java.io.File
import java.util.UUID
@Inject
class SessionPathsFactory(
@BaseDirectory private val baseDirectory: File,
@CacheDirectory private val cacheDirectory: File,
) {
fun create(): SessionPaths {
val subPath = UUID.randomUUID().toString()
return SessionPaths(
fileDirectory = File(baseDirectory, subPath),
cacheDirectory = File(cacheDirectory, subPath),
)
}
}
@@ -0,0 +1,62 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-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.impl.permalink
import android.net.Uri
import androidx.core.net.toUri
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.appconfig.MatrixConfiguration
import io.element.android.libraries.core.extensions.replacePrefix
import io.element.android.libraries.matrix.api.permalink.MatrixToConverter
/**
* Mapping of an input URI to a matrix.to compliant URI.
*/
@ContributesBinding(AppScope::class)
class DefaultMatrixToConverter : MatrixToConverter {
/**
* Try to convert a URL from an element web instance or from a client permalink to a matrix.to url.
* To be successfully converted, URL path should contain one of the [SUPPORTED_PATHS].
* Examples:
* - https://riot.im/develop/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org
* - https://app.element.io/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org
* - https://www.example.org/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org
* Also convert links coming from the matrix.to website:
* - element://room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org
* - element://user/@alice:matrix.org -> https://matrix.to/#/@alice:matrix.org
*/
override fun convert(uri: Uri): Uri? {
val uriString = uri.toString()
// Handle links coming from the matrix.to website.
.replacePrefix(MATRIX_TO_CUSTOM_SCHEME_BASE_URL, "https://app.element.io/#/")
val baseUrl = MatrixConfiguration.MATRIX_TO_PERMALINK_BASE_URL
return when {
// URL is already a matrix.to
uriString.startsWith(baseUrl) -> uri
// Web or client url
SUPPORTED_PATHS.any { it in uriString } -> {
val path = SUPPORTED_PATHS.first { it in uriString }
(baseUrl + uriString.substringAfter(path)).toUri()
}
// URL is not supported
else -> null
}
}
companion object {
private const val MATRIX_TO_CUSTOM_SCHEME_BASE_URL = "element://"
private val SUPPORTED_PATHS = listOf(
"/#/room/",
"/#/user/",
"/#/group/"
)
}
}
@@ -0,0 +1,41 @@
/*
* 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.impl.permalink
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilderError
import org.matrix.rustcomponents.sdk.matrixToRoomAliasPermalink
import org.matrix.rustcomponents.sdk.matrixToUserPermalink
@ContributesBinding(AppScope::class)
class DefaultPermalinkBuilder : PermalinkBuilder {
override fun permalinkForUser(userId: UserId): Result<String> {
if (!MatrixPatterns.isUserId(userId.value)) {
return Result.failure(PermalinkBuilderError.InvalidData)
}
return runCatchingExceptions {
matrixToUserPermalink(userId.value)
}
}
override fun permalinkForRoomAlias(roomAlias: RoomAlias): Result<String> {
if (!MatrixPatterns.isRoomAlias(roomAlias.value)) {
return Result.failure(PermalinkBuilderError.InvalidData)
}
return runCatchingExceptions {
matrixToRoomAliasPermalink(roomAlias.value)
}
}
}
@@ -0,0 +1,86 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.permalink
import androidx.core.net.toUri
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.MatrixToConverter
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import kotlinx.collections.immutable.toImmutableList
import org.matrix.rustcomponents.sdk.MatrixId
import org.matrix.rustcomponents.sdk.parseMatrixEntityFrom
/**
* This class turns a uri to a [PermalinkData].
* element-based domains (e.g. https://app.element.io/#/user/@chagai95:matrix.org) permalinks
* or matrix.to permalinks (e.g. https://matrix.to/#/@chagai95:matrix.org)
* or client permalinks (e.g. <clientPermalinkBaseUrl>user/@chagai95:matrix.org)
* or matrix: permalinks (e.g. matrix:u/chagai95:matrix.org)
*/
@ContributesBinding(AppScope::class)
class DefaultPermalinkParser(
private val matrixToConverter: MatrixToConverter
) : PermalinkParser {
/**
* Turns a uri string to a [PermalinkData].
* https://github.com/matrix-org/matrix-doc/blob/master/proposals/1704-matrix.to-permalinks.md
*/
override fun parse(uriString: String): PermalinkData {
val uri = uriString.toUri()
val matrixToUri = if (uri.scheme == "matrix") {
// take matrix: URI as is to [parseMatrixEntityFrom]
uri
} else {
// the client or element-based domain permalinks (e.g. https://app.element.io/#/user/@chagai95:matrix.org) don't have the
// mxid in the first param (like matrix.to does - https://matrix.to/#/@chagai95:matrix.org) but rather in the second after /user/ so /user/mxid
// so convert URI to matrix.to to simplify parsing process
matrixToConverter.convert(uri) ?: return PermalinkData.FallbackLink(uri)
}
val result = runCatchingExceptions {
parseMatrixEntityFrom(matrixToUri.toString())
}.getOrNull()
return if (result == null) {
PermalinkData.FallbackLink(uri)
} else {
val viaParameters = result.via.toImmutableList()
when (val id = result.id) {
is MatrixId.User -> PermalinkData.UserLink(
userId = UserId(id.id),
)
is MatrixId.Room -> PermalinkData.RoomLink(
roomIdOrAlias = RoomId(id.id).toRoomIdOrAlias(),
viaParameters = viaParameters,
)
is MatrixId.RoomAlias -> PermalinkData.RoomLink(
roomIdOrAlias = RoomAlias(id.alias).toRoomIdOrAlias(),
viaParameters = viaParameters,
)
is MatrixId.EventOnRoomId -> PermalinkData.RoomLink(
roomIdOrAlias = RoomId(id.roomId).toRoomIdOrAlias(),
eventId = EventId(id.eventId),
viaParameters = viaParameters,
)
is MatrixId.EventOnRoomAlias -> PermalinkData.RoomLink(
roomIdOrAlias = RoomAlias(id.alias).toRoomIdOrAlias(),
eventId = EventId(id.eventId),
viaParameters = viaParameters,
)
}
}
}
}
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.impl.platform
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.api.platform.InitPlatformService
import io.element.android.libraries.matrix.api.tracing.TracingConfiguration
import io.element.android.libraries.matrix.impl.tracing.map
import org.matrix.rustcomponents.sdk.initPlatform
@ContributesBinding(AppScope::class)
class RustInitPlatformService : InitPlatformService {
override fun init(tracingConfiguration: TracingConfiguration) {
initPlatform(
config = tracingConfiguration.map(),
useLightweightTokioRuntime = false
)
}
}
@@ -0,0 +1,17 @@
/*
* 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.impl.poll
import io.element.android.libraries.matrix.api.poll.PollAnswer
import org.matrix.rustcomponents.sdk.PollAnswer as RustPollAnswer
fun RustPollAnswer.map(): PollAnswer = PollAnswer(
id = id,
text = text,
)
@@ -0,0 +1,22 @@
/*
* 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.impl.poll
import io.element.android.libraries.matrix.api.poll.PollKind
import org.matrix.rustcomponents.sdk.PollKind as RustPollKind
fun RustPollKind.map(): PollKind = when (this) {
RustPollKind.DISCLOSED -> PollKind.Disclosed
RustPollKind.UNDISCLOSED -> PollKind.Undisclosed
}
fun PollKind.toInner(): RustPollKind = when (this) {
PollKind.Disclosed -> RustPollKind.DISCLOSED
PollKind.Undisclosed -> RustPollKind.UNDISCLOSED
}
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.proxy
import android.content.Context
import android.net.ConnectivityManager
import android.provider.Settings
import androidx.core.content.getSystemService
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.annotations.ApplicationContext
import timber.log.Timber
/**
* Provides the proxy settings from the system.
* Note that you can configure the global proxy using adb like this:
* ```
* adb shell settings put global http_proxy https://proxy.example.com:8080
* ```
* and to remove it:
* ```
* adb shell settings delete global http_proxy
* ```
*/
@ContributesBinding(AppScope::class)
class DefaultProxyProvider(
@ApplicationContext
private val context: Context
) : ProxyProvider {
override fun provides(): String? {
val defaultProxy = context.getSystemService<ConnectivityManager>()?.defaultProxy
if (defaultProxy == null) {
// Note: can be tested by running:
// adb shell settings put global http_proxy :0
Timber.d("No default proxy")
return null
}
return Settings.Global.getString(context.contentResolver, Settings.Global.HTTP_PROXY)
?.also {
Timber.d("Using global proxy")
}
}
}
@@ -0,0 +1,13 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.proxy
interface ProxyProvider {
fun provides(): String?
}
@@ -0,0 +1,66 @@
/*
* 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.impl.pushers
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.pusher.PushersService
import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData
import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData
import io.element.android.libraries.matrix.impl.exception.mapClientException
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.HttpPusherData
import org.matrix.rustcomponents.sdk.PushFormat
import org.matrix.rustcomponents.sdk.PusherIdentifiers
import org.matrix.rustcomponents.sdk.PusherKind
class RustPushersService(
private val client: Client,
private val dispatchers: CoroutineDispatchers
) : PushersService {
override suspend fun setHttpPusher(setHttpPusherData: SetHttpPusherData): Result<Unit> {
return withContext(dispatchers.io) {
runCatchingExceptions {
client.setPusher(
identifiers = PusherIdentifiers(
pushkey = setHttpPusherData.pushKey,
appId = setHttpPusherData.appId
),
kind = PusherKind.Http(
data = HttpPusherData(
url = setHttpPusherData.url,
format = PushFormat.EVENT_ID_ONLY,
defaultPayload = setHttpPusherData.defaultPayload
)
),
appDisplayName = setHttpPusherData.appDisplayName,
deviceDisplayName = setHttpPusherData.deviceDisplayName,
profileTag = setHttpPusherData.profileTag,
lang = setHttpPusherData.lang
)
}
.mapFailure { it.mapClientException() }
}
}
override suspend fun unsetHttpPusher(unsetHttpPusherData: UnsetHttpPusherData): Result<Unit> {
return withContext(dispatchers.io) {
runCatchingExceptions {
client.deletePusher(
identifiers = PusherIdentifiers(
pushkey = unsetHttpPusherData.pushKey,
appId = unsetHttpPusherData.appId
),
)
}
}
}
}
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.room
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.errors.FocusEventException
import org.matrix.rustcomponents.sdk.FocusEventException as RustFocusEventException
fun Throwable.toFocusEventException(): Throwable {
return when (this) {
is RustFocusEventException -> {
when (this) {
is RustFocusEventException.InvalidEventId -> {
FocusEventException.InvalidEventId(eventId, err)
}
is RustFocusEventException.EventNotFound -> {
FocusEventException.EventNotFound(EventId(eventId))
}
is RustFocusEventException.Other -> {
FocusEventException.Other(msg)
}
}
}
else -> {
this
}
}
}
@@ -0,0 +1,509 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.impl.room
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScope
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.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.SendHandle
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityStateChange
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.CreateTimelineParams
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomNotificationSettingsState
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
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.roomdirectory.RoomVisibility
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.impl.core.RustSendHandle
import io.element.android.libraries.matrix.impl.mapper.map
import io.element.android.libraries.matrix.impl.room.history.map
import io.element.android.libraries.matrix.impl.room.join.map
import io.element.android.libraries.matrix.impl.room.knock.RustKnockRequest
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
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.util.MessageEventContent
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver
import io.element.android.libraries.matrix.impl.widget.generateWidgetWebViewUrl
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.DateDividerMode
import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener
import org.matrix.rustcomponents.sdk.KnockRequestsListener
import org.matrix.rustcomponents.sdk.RoomMessageEventMessageType
import org.matrix.rustcomponents.sdk.TimelineConfiguration
import org.matrix.rustcomponents.sdk.TimelineFilter
import org.matrix.rustcomponents.sdk.TimelineFocus
import org.matrix.rustcomponents.sdk.TypingNotificationsListener
import org.matrix.rustcomponents.sdk.UserPowerLevelUpdate
import org.matrix.rustcomponents.sdk.WidgetCapabilities
import org.matrix.rustcomponents.sdk.WidgetCapabilitiesProvider
import org.matrix.rustcomponents.sdk.getElementCallRequiredPermissions
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import uniffi.matrix_sdk.RoomPowerLevelChanges
import uniffi.matrix_sdk_ui.TimelineReadReceiptTracking
import kotlin.coroutines.cancellation.CancellationException
import org.matrix.rustcomponents.sdk.IdentityStatusChange as RustIdentityStateChange
import org.matrix.rustcomponents.sdk.KnockRequest as InnerKnockRequest
import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
class JoinedRustRoom(
private val baseRoom: RustBaseRoom,
private val liveInnerTimeline: InnerTimeline,
private val notificationSettingsService: NotificationSettingsService,
private val coroutineDispatchers: CoroutineDispatchers,
private val systemClock: SystemClock,
private val roomContentForwarder: RoomContentForwarder,
private val featureFlagService: FeatureFlagService,
) : JoinedRoom, BaseRoom by baseRoom {
// Create a dispatcher for all room methods...
private val roomDispatcher = coroutineDispatchers.io.limitedParallelism(32)
private val innerRoom = baseRoom.innerRoom
override val roomTypingMembersFlow: Flow<List<UserId>> = mxCallbackFlow {
val initial = emptyList<UserId>()
channel.trySend(initial)
innerRoom.subscribeToTypingNotifications(object : TypingNotificationsListener {
override fun call(typingUserIds: List<String>) {
channel.trySend(
typingUserIds
.filter { it != sessionId.value }
.map(::UserId)
)
}
})
}
override val identityStateChangesFlow: Flow<List<IdentityStateChange>> = mxCallbackFlow {
val initial = emptyList<IdentityStateChange>()
channel.trySend(initial)
innerRoom.subscribeToIdentityStatusChanges(object : IdentityStatusChangeListener {
override fun call(identityStatusChange: List<RustIdentityStateChange>) {
channel.trySend(
identityStatusChange.map {
IdentityStateChange(
userId = UserId(it.userId),
identityState = it.changedTo.map(),
)
}
)
}
})
}
override val knockRequestsFlow: Flow<List<KnockRequest>> = mxCallbackFlow {
innerRoom.subscribeToKnockRequests(object : KnockRequestsListener {
override fun call(joinRequests: List<InnerKnockRequest>) {
val knockRequests = joinRequests.map { RustKnockRequest(it) }
channel.trySend(knockRequests)
}
})
}
override val roomNotificationSettingsStateFlow = MutableStateFlow<RoomNotificationSettingsState>(RoomNotificationSettingsState.Unknown)
override val liveTimeline = liveInnerTimeline.map(mode = Timeline.Mode.Live)
override val syncUpdateFlow = flow {
var counter = 0L
liveTimeline.onSyncedEventReceived.collect {
emit(++counter)
}
}.stateIn(
scope = roomCoroutineScope,
started = WhileSubscribed(),
initialValue = 0L,
)
init {
subscribeToRoomMembersChange()
}
private fun subscribeToRoomMembersChange() {
val powerLevelChanges = roomInfoFlow.map { it.roomPowerLevels }.distinctUntilChanged()
val membershipChanges = liveTimeline.membershipChangeEventReceived.onStart { emit(Unit) }
combine(membershipChanges, powerLevelChanges) { _, _ -> }
// Skip initial one
.drop(1)
// The new events should already be in the SDK cache, no need to fetch them from the server
.onEach { baseRoom.roomMemberListFetcher.fetchRoomMembers(source = RoomMemberListFetcher.Source.CACHE) }
.launchIn(roomCoroutineScope)
.invokeOnCompletion {
Timber.d("Observing membership changes for room $roomId stopped, reason: $it")
}
}
override suspend fun createTimeline(
createTimelineParams: CreateTimelineParams,
): Result<Timeline> = withContext(roomDispatcher) {
val hideThreadedEvents = featureFlagService.isFeatureEnabled(FeatureFlags.Threads)
val focus = when (createTimelineParams) {
is CreateTimelineParams.PinnedOnly -> TimelineFocus.PinnedEvents(
maxEventsToLoad = 100u,
maxConcurrentRequests = 10u,
)
is CreateTimelineParams.MediaOnly -> TimelineFocus.Live(hideThreadedEvents = hideThreadedEvents)
is CreateTimelineParams.Focused -> TimelineFocus.Event(
eventId = createTimelineParams.focusedEventId.value,
numContextEvents = 50u,
hideThreadedEvents = hideThreadedEvents,
)
is CreateTimelineParams.MediaOnlyFocused -> TimelineFocus.Event(
eventId = createTimelineParams.focusedEventId.value,
numContextEvents = 50u,
// Never hide threaded events in media focused timeline
hideThreadedEvents = false,
)
is CreateTimelineParams.Threaded -> TimelineFocus.Thread(
rootEventId = createTimelineParams.threadRootEventId.value,
)
}
val filter = when (createTimelineParams) {
is CreateTimelineParams.MediaOnly,
is CreateTimelineParams.MediaOnlyFocused -> TimelineFilter.OnlyMessage(
types = listOf(
RoomMessageEventMessageType.FILE,
RoomMessageEventMessageType.IMAGE,
RoomMessageEventMessageType.VIDEO,
RoomMessageEventMessageType.AUDIO,
)
)
is CreateTimelineParams.Focused,
CreateTimelineParams.PinnedOnly,
is CreateTimelineParams.Threaded -> TimelineFilter.All
}
val internalIdPrefix = when (createTimelineParams) {
is CreateTimelineParams.PinnedOnly -> "pinned_events"
is CreateTimelineParams.Focused -> "focus_${createTimelineParams.focusedEventId}"
is CreateTimelineParams.MediaOnly -> "MediaGallery_"
is CreateTimelineParams.MediaOnlyFocused -> "MediaGallery_${createTimelineParams.focusedEventId}"
is CreateTimelineParams.Threaded -> "Thread_${createTimelineParams.threadRootEventId}"
}
// Note that for TimelineFilter.MediaOnlyFocused, the date separator will be filtered out,
// but there is no way to exclude data separator at the moment.
val dateDividerMode = when (createTimelineParams) {
is CreateTimelineParams.MediaOnly,
is CreateTimelineParams.MediaOnlyFocused -> DateDividerMode.MONTHLY
is CreateTimelineParams.Focused,
CreateTimelineParams.PinnedOnly,
is CreateTimelineParams.Threaded -> DateDividerMode.DAILY
}
// Track read receipts only for focused timeline for performance optimization
val trackReadReceipts = createTimelineParams is CreateTimelineParams.Focused
runCatchingExceptions {
innerRoom.timelineWithConfiguration(
configuration = TimelineConfiguration(
focus = focus,
filter = filter,
internalIdPrefix = internalIdPrefix,
dateDividerMode = dateDividerMode,
trackReadReceipts = if (trackReadReceipts) TimelineReadReceiptTracking.ALL_EVENTS else TimelineReadReceiptTracking.DISABLED,
reportUtds = true,
)
).let { innerTimeline ->
val mode = when (createTimelineParams) {
is CreateTimelineParams.Focused -> Timeline.Mode.FocusedOnEvent(createTimelineParams.focusedEventId)
is CreateTimelineParams.MediaOnly -> Timeline.Mode.Media
is CreateTimelineParams.MediaOnlyFocused -> Timeline.Mode.FocusedOnEvent(createTimelineParams.focusedEventId)
CreateTimelineParams.PinnedOnly -> Timeline.Mode.PinnedEvents
is CreateTimelineParams.Threaded -> Timeline.Mode.Thread(createTimelineParams.threadRootEventId)
}
innerTimeline.map(mode = mode)
}
}.mapFailure {
when (createTimelineParams) {
is CreateTimelineParams.Focused,
is CreateTimelineParams.MediaOnlyFocused,
is CreateTimelineParams.Threaded -> it.toFocusEventException()
CreateTimelineParams.MediaOnly,
CreateTimelineParams.PinnedOnly -> it
}
}.onFailure {
if (it is CancellationException) {
throw it
}
}
}
override suspend fun editMessage(
eventId: EventId,
body: String,
htmlBody: String?,
intentionalMentions: List<IntentionalMention>
): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
MessageEventContent.from(body, htmlBody, intentionalMentions).use { newContent ->
innerRoom.edit(eventId.value, newContent)
}
}
}
override suspend fun typingNotice(isTyping: Boolean) = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.typingNotice(isTyping)
}
}
override suspend fun inviteUserById(id: UserId): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.inviteUserById(id.value)
}
}
override suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.uploadAvatar(mimeType, data, null)
}
}
override suspend fun removeAvatar(): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.removeAvatar()
}
}
override suspend fun setName(name: String): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.setName(name)
}
}
override suspend fun setTopic(topic: String): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.setTopic(topic)
}
}
override suspend fun reportContent(eventId: EventId, reason: String, blockUserId: UserId?): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.reportContent(eventId = eventId.value, score = null, reason = reason)
if (blockUserId != null) {
innerRoom.ignoreUser(blockUserId.value)
}
}
}
override suspend fun updateRoomNotificationSettings(): Result<Unit> = withContext(roomDispatcher) {
val currentState = roomNotificationSettingsStateFlow.value
val currentRoomNotificationSettings = currentState.roomNotificationSettings()
roomNotificationSettingsStateFlow.value = RoomNotificationSettingsState.Pending(prevRoomNotificationSettings = currentRoomNotificationSettings)
runCatchingExceptions {
val isEncrypted = roomInfoFlow.value.isEncrypted ?: getUpdatedIsEncrypted().getOrThrow()
notificationSettingsService.getRoomNotificationSettings(roomId, isEncrypted, isOneToOne).getOrThrow()
}.map {
roomNotificationSettingsStateFlow.value = RoomNotificationSettingsState.Ready(it)
}.onFailure {
roomNotificationSettingsStateFlow.value = RoomNotificationSettingsState.Error(
prevRoomNotificationSettings = currentRoomNotificationSettings,
failure = it
)
}
}
override suspend fun updateCanonicalAlias(canonicalAlias: RoomAlias?, alternativeAliases: List<RoomAlias>): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.updateCanonicalAlias(canonicalAlias?.value, alternativeAliases.map { it.value })
}
}
override suspend fun publishRoomAliasInRoomDirectory(roomAlias: RoomAlias): Result<Boolean> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.publishRoomAliasInRoomDirectory(roomAlias.value)
}
}
override suspend fun removeRoomAliasFromRoomDirectory(roomAlias: RoomAlias): Result<Boolean> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.removeRoomAliasFromRoomDirectory(roomAlias.value)
}
}
override suspend fun updateRoomVisibility(roomVisibility: RoomVisibility): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.updateRoomVisibility(roomVisibility.map())
}
}
override suspend fun updateHistoryVisibility(historyVisibility: RoomHistoryVisibility): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.updateHistoryVisibility(historyVisibility.map())
}
}
override suspend fun enableEncryption(): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.enableEncryption()
}
}
override suspend fun updateJoinRule(joinRule: JoinRule): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.updateJoinRules(joinRule.map())
}
}
override suspend fun updateUsersRoles(changes: List<UserRoleChange>): Result<Unit> {
return runCatchingExceptions {
val powerLevelChanges = changes.map { UserPowerLevelUpdate(it.userId.value, it.powerLevel) }
innerRoom.updatePowerLevelsForUsers(powerLevelChanges)
}
}
override suspend fun updatePowerLevels(roomPowerLevelsValues: RoomPowerLevelsValues): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
val changes = RoomPowerLevelChanges(
ban = roomPowerLevelsValues.ban,
invite = roomPowerLevelsValues.invite,
kick = roomPowerLevelsValues.kick,
redact = roomPowerLevelsValues.redactEvents,
eventsDefault = roomPowerLevelsValues.sendEvents,
roomName = roomPowerLevelsValues.roomName,
roomAvatar = roomPowerLevelsValues.roomAvatar,
roomTopic = roomPowerLevelsValues.roomTopic,
)
innerRoom.applyPowerLevelChanges(changes)
}
}
override suspend fun resetPowerLevels(): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.resetPowerLevels().let {}
}
}
override suspend fun kickUser(userId: UserId, reason: String?): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.kickUser(userId.value, reason)
}
}
override suspend fun banUser(userId: UserId, reason: String?): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.banUser(userId.value, reason)
}
}
override suspend fun unbanUser(userId: UserId, reason: String?): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.unbanUser(userId.value, reason)
}
}
override suspend fun generateWidgetWebViewUrl(
widgetSettings: MatrixWidgetSettings,
clientId: String,
languageTag: String?,
theme: String?,
) = withContext(roomDispatcher) {
runCatchingExceptions {
widgetSettings.generateWidgetWebViewUrl(innerRoom, clientId, languageTag, theme)
}
}
override fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result<MatrixWidgetDriver> {
return runCatchingExceptions {
RustWidgetDriver(
widgetSettings = widgetSettings,
room = innerRoom,
widgetCapabilitiesProvider = object : WidgetCapabilitiesProvider {
override fun acquireCapabilities(capabilities: WidgetCapabilities): WidgetCapabilities {
return getElementCallRequiredPermissions(sessionId.value, baseRoom.deviceId.value)
}
},
)
}
}
override suspend fun setSendQueueEnabled(enabled: Boolean) {
withContext(roomDispatcher) {
Timber.d("setSendQueuesEnabled: $enabled")
runCatchingExceptions {
innerRoom.enableSendQueue(enabled)
}
}
}
override suspend fun ignoreDeviceTrustAndResend(devices: Map<UserId, List<DeviceId>>, sendHandle: SendHandle) = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.ignoreDeviceTrustAndResend(
devices = devices.entries.associate { entry ->
entry.key.value to entry.value.map { it.value }
},
sendHandle = (sendHandle as RustSendHandle).inner,
)
}
}
override suspend fun withdrawVerificationAndResend(userIds: List<UserId>, sendHandle: SendHandle) = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.withdrawVerificationAndResend(
userIds = userIds.map { it.value },
sendHandle = (sendHandle as RustSendHandle).inner,
)
}
}
override fun close() = destroy()
override fun destroy() {
baseRoom.destroy()
liveInnerTimeline.destroy()
Timber.d("Room $roomId destroyed")
}
private fun InnerTimeline.map(
mode: Timeline.Mode,
): Timeline {
val timelineCoroutineScope = roomCoroutineScope.childScope(coroutineDispatchers.main, "TimelineScope-$roomId-$this")
return RustTimeline(
mode = mode,
joinedRoom = this@JoinedRustRoom,
inner = this@map,
systemClock = systemClock,
coroutineScope = timelineCoroutineScope,
dispatcher = roomDispatcher,
roomContentForwarder = roomContentForwarder,
)
}
}
@@ -0,0 +1,18 @@
/*
* 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.impl.room
import io.element.android.libraries.matrix.api.room.IntentionalMention
import org.matrix.rustcomponents.sdk.Mentions
fun List<IntentionalMention>.map(): Mentions {
val hasRoom = any { it is IntentionalMention.Room }
val userIds = filterIsInstance<IntentionalMention.User>().map { it.userId.value }
return Mentions(userIds, hasRoom)
}
@@ -0,0 +1,66 @@
/*
* 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.impl.room
import io.element.android.libraries.matrix.api.room.MessageEventType
import org.matrix.rustcomponents.sdk.MessageLikeEventType
fun MessageEventType.map(): MessageLikeEventType = when (this) {
MessageEventType.CallAnswer -> MessageLikeEventType.CallAnswer
MessageEventType.CallInvite -> MessageLikeEventType.CallInvite
MessageEventType.CallHangup -> MessageLikeEventType.CallHangup
MessageEventType.CallCandidates -> MessageLikeEventType.CallCandidates
MessageEventType.RtcNotification -> MessageLikeEventType.RtcNotification
MessageEventType.KeyVerificationReady -> MessageLikeEventType.KeyVerificationReady
MessageEventType.KeyVerificationStart -> MessageLikeEventType.KeyVerificationStart
MessageEventType.KeyVerificationCancel -> MessageLikeEventType.KeyVerificationCancel
MessageEventType.KeyVerificationAccept -> MessageLikeEventType.KeyVerificationAccept
MessageEventType.KeyVerificationKey -> MessageLikeEventType.KeyVerificationKey
MessageEventType.KeyVerificationMac -> MessageLikeEventType.KeyVerificationMac
MessageEventType.KeyVerificationDone -> MessageLikeEventType.KeyVerificationDone
MessageEventType.Reaction -> MessageLikeEventType.Reaction
MessageEventType.RoomEncrypted -> MessageLikeEventType.RoomEncrypted
MessageEventType.RoomMessage -> MessageLikeEventType.RoomMessage
MessageEventType.RoomRedaction -> MessageLikeEventType.RoomRedaction
MessageEventType.Sticker -> MessageLikeEventType.Sticker
MessageEventType.PollEnd -> MessageLikeEventType.PollEnd
MessageEventType.PollResponse -> MessageLikeEventType.PollResponse
MessageEventType.PollStart -> MessageLikeEventType.PollStart
MessageEventType.UnstablePollEnd -> MessageLikeEventType.UnstablePollEnd
MessageEventType.UnstablePollResponse -> MessageLikeEventType.UnstablePollResponse
MessageEventType.UnstablePollStart -> MessageLikeEventType.UnstablePollStart
is MessageEventType.Other -> MessageLikeEventType.Other(type)
}
fun MessageLikeEventType.map(): MessageEventType = when (this) {
MessageLikeEventType.CallAnswer -> MessageEventType.CallAnswer
MessageLikeEventType.CallInvite -> MessageEventType.CallInvite
MessageLikeEventType.CallHangup -> MessageEventType.CallHangup
MessageLikeEventType.CallCandidates -> MessageEventType.CallCandidates
MessageLikeEventType.RtcNotification -> MessageEventType.RtcNotification
MessageLikeEventType.KeyVerificationReady -> MessageEventType.KeyVerificationReady
MessageLikeEventType.KeyVerificationStart -> MessageEventType.KeyVerificationStart
MessageLikeEventType.KeyVerificationCancel -> MessageEventType.KeyVerificationCancel
MessageLikeEventType.KeyVerificationAccept -> MessageEventType.KeyVerificationAccept
MessageLikeEventType.KeyVerificationKey -> MessageEventType.KeyVerificationKey
MessageLikeEventType.KeyVerificationMac -> MessageEventType.KeyVerificationMac
MessageLikeEventType.KeyVerificationDone -> MessageEventType.KeyVerificationDone
MessageLikeEventType.Reaction -> MessageEventType.Reaction
MessageLikeEventType.RoomEncrypted -> MessageEventType.RoomEncrypted
MessageLikeEventType.RoomMessage -> MessageEventType.RoomMessage
MessageLikeEventType.RoomRedaction -> MessageEventType.RoomRedaction
MessageLikeEventType.Sticker -> MessageEventType.Sticker
MessageLikeEventType.PollEnd -> MessageEventType.PollEnd
MessageLikeEventType.PollResponse -> MessageEventType.PollResponse
MessageLikeEventType.PollStart -> MessageEventType.PollStart
MessageLikeEventType.UnstablePollEnd -> MessageEventType.UnstablePollEnd
MessageLikeEventType.UnstablePollResponse -> MessageEventType.UnstablePollResponse
MessageLikeEventType.UnstablePollStart -> MessageEventType.UnstablePollStart
is MessageLikeEventType.Other -> MessageEventType.Other(v1)
}
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.room
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.NotJoinedRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipDetails
import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
class NotJoinedRustRoom(
private val sessionId: SessionId,
override val localRoom: RustBaseRoom?,
override val previewInfo: RoomPreviewInfo,
) : NotJoinedRoom {
override suspend fun membershipDetails(): Result<RoomMembershipDetails?> = runCatchingExceptions {
val room = localRoom?.innerRoom ?: return@runCatchingExceptions null
val (ownMember, senderInfo) = room.memberWithSenderInfo(sessionId.value)
RoomMembershipDetails(
currentUserMember = RoomMemberMapper.map(ownMember),
senderMember = senderInfo?.let { RoomMemberMapper.map(it) },
)
}
override fun close() {
localRoom?.close()
}
}
@@ -0,0 +1,73 @@
/*
* 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.impl.room
import io.element.android.libraries.core.coroutine.parallelMap
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.RoomId
import io.element.android.libraries.matrix.api.room.ForwardEventException
import io.element.android.libraries.matrix.impl.roomlist.roomOrNull
import io.element.android.libraries.matrix.impl.timeline.runWithTimelineListenerRegistered
import kotlinx.coroutines.withTimeout
import org.matrix.rustcomponents.sdk.MsgLikeKind
import org.matrix.rustcomponents.sdk.RoomListService
import org.matrix.rustcomponents.sdk.Timeline
import org.matrix.rustcomponents.sdk.TimelineItemContent
import org.matrix.rustcomponents.sdk.contentWithoutRelationFromMessage
import kotlin.time.Duration.Companion.milliseconds
/**
* Helper to forward event contents from a room to a set of other rooms.
* @param roomListService the [RoomListService] to fetch room instances to forward the event to
*/
class RoomContentForwarder(
private val roomListService: RoomListService,
) {
/**
* Forwards the event with the given [eventId] from the [fromTimeline] to the given [toRoomIds].
* @param fromTimeline the room to forward the event from
* @param eventId the id of the event to forward
* @param toRoomIds the ids of the rooms to forward the event to
* @param timeoutMs the maximum time in milliseconds to wait for the event to be sent to a room
*/
suspend fun forward(
fromTimeline: Timeline,
eventId: EventId,
toRoomIds: List<RoomId>,
timeoutMs: Long = 5000L
) {
val messageLikeContent = (fromTimeline.getEventTimelineItemByEventId(eventId.value).content as? TimelineItemContent.MsgLike)?.content
?: throw ForwardEventException(toRoomIds)
val content = (messageLikeContent.kind as? MsgLikeKind.Message)?.content
?: throw ForwardEventException(toRoomIds)
val targetRooms = toRoomIds.mapNotNull { roomId -> roomListService.roomOrNull(roomId.value) }
val failedForwardingTo = mutableSetOf<RoomId>()
targetRooms.parallelMap { room ->
room.use { targetRoom ->
runCatchingExceptions {
// Sending a message requires a registered timeline listener
targetRoom.timeline().runWithTimelineListenerRegistered {
withTimeout(timeoutMs.milliseconds) {
targetRoom.timeline().send(contentWithoutRelationFromMessage(content))
}
}
}
}.onFailure {
failedForwardingTo.add(RoomId(room.id()))
}
}
if (failedForwardingTo.isNotEmpty()) {
throw ForwardEventException(failedForwardingTo.toList())
}
}
}
@@ -0,0 +1,25 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.room
import io.element.android.libraries.matrix.api.user.MatrixUser
import org.matrix.rustcomponents.sdk.RoomInfo
/**
* Extract the heroes from the room info.
* For now we only use heroes for direct rooms with 2 members.
* Also we keep the heroes only if there is one single hero.
*/
fun RoomInfo.elementHeroes(): List<MatrixUser> {
return heroes
.takeIf { isDirect && activeMembersCount.toLong() == 2L }
?.takeIf { it.size == 1 }
?.map { it.map() }
.orEmpty()
}
@@ -0,0 +1,109 @@
/*
* 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.impl.room
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.impl.room.history.map
import io.element.android.libraries.matrix.impl.room.join.map
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
import io.element.android.libraries.matrix.impl.room.powerlevels.RoomPowerLevelsValuesMapper
import io.element.android.libraries.matrix.impl.room.tombstone.map
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import org.matrix.rustcomponents.sdk.Membership
import org.matrix.rustcomponents.sdk.RoomHero
import uniffi.matrix_sdk_base.EncryptionState
import org.matrix.rustcomponents.sdk.Membership as RustMembership
import org.matrix.rustcomponents.sdk.RoomInfo as RustRoomInfo
import org.matrix.rustcomponents.sdk.RoomNotificationMode as RustRoomNotificationMode
import org.matrix.rustcomponents.sdk.RoomPowerLevels as RustRoomPowerLevels
class RoomInfoMapper {
fun map(rustRoomInfo: RustRoomInfo): RoomInfo = rustRoomInfo.let {
return RoomInfo(
id = RoomId(it.id),
creators = it.creators.orEmpty().map(::UserId).toImmutableList(),
name = it.displayName,
rawName = it.rawName,
topic = it.topic,
avatarUrl = it.avatarUrl,
isPublic = it.isPublic,
isDirect = it.isDirect,
isEncrypted = when (it.encryptionState) {
EncryptionState.ENCRYPTED -> true
EncryptionState.NOT_ENCRYPTED -> false
EncryptionState.UNKNOWN -> null
},
joinRule = it.joinRule?.map(),
isSpace = it.isSpace,
isFavorite = it.isFavourite,
canonicalAlias = it.canonicalAlias?.let(::RoomAlias),
alternativeAliases = it.alternativeAliases.map(::RoomAlias).toImmutableList(),
currentUserMembership = it.membership.map(),
inviter = it.inviter?.let(RoomMemberMapper::map),
activeMembersCount = it.activeMembersCount.toLong(),
invitedMembersCount = it.invitedMembersCount.toLong(),
joinedMembersCount = it.joinedMembersCount.toLong(),
roomPowerLevels = it.powerLevels?.let(::mapPowerLevels),
highlightCount = it.highlightCount.toLong(),
notificationCount = it.notificationCount.toLong(),
userDefinedNotificationMode = it.cachedUserDefinedNotificationMode?.map(),
hasRoomCall = it.hasRoomCall,
activeRoomCallParticipants = it.activeRoomCallParticipants.map(::UserId).toImmutableList(),
heroes = it.elementHeroes().toImmutableList(),
pinnedEventIds = it.pinnedEventIds.map(::EventId).toImmutableList(),
isMarkedUnread = it.isMarkedUnread,
numUnreadMessages = it.numUnreadMessages.toLong(),
numUnreadMentions = it.numUnreadMentions.toLong(),
numUnreadNotifications = it.numUnreadNotifications.toLong(),
historyVisibility = it.historyVisibility.map(),
successorRoom = it.successorRoom?.map(),
roomVersion = it.roomVersion,
privilegedCreatorRole = it.privilegedCreatorsRole,
)
}
}
fun RustMembership.map(): CurrentUserMembership = when (this) {
RustMembership.INVITED -> CurrentUserMembership.INVITED
RustMembership.JOINED -> CurrentUserMembership.JOINED
RustMembership.LEFT -> CurrentUserMembership.LEFT
Membership.KNOCKED -> CurrentUserMembership.KNOCKED
RustMembership.BANNED -> CurrentUserMembership.BANNED
}
fun RustRoomNotificationMode.map(): RoomNotificationMode = when (this) {
RustRoomNotificationMode.ALL_MESSAGES -> RoomNotificationMode.ALL_MESSAGES
RustRoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
RustRoomNotificationMode.MUTE -> RoomNotificationMode.MUTE
}
/**
* Map a RoomHero to a MatrixUser. There is not need to create a RoomHero type on the application side.
*/
fun RoomHero.map(): MatrixUser = MatrixUser(
userId = UserId(userId),
displayName = displayName,
avatarUrl = avatarUrl
)
fun mapPowerLevels(roomPowerLevels: RustRoomPowerLevels): RoomPowerLevels {
return RoomPowerLevels(
values = RoomPowerLevelsValuesMapper.map(roomPowerLevels.values()),
users = roomPowerLevels.userPowerLevels().mapKeys { (key, _) -> UserId(key) }.toImmutableMap()
)
}
@@ -0,0 +1,63 @@
/*
* 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.impl.room
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.RoomListService
import timber.log.Timber
class RoomSyncSubscriber(
private val roomListService: RoomListService,
private val dispatchers: CoroutineDispatchers,
) {
private val subscribedRoomIds = mutableSetOf<RoomId>()
private val mutex = Mutex()
suspend fun subscribe(roomId: RoomId) {
mutex.withLock {
withContext(dispatchers.io) {
try {
if (!isSubscribedTo(roomId)) {
Timber.d("Subscribing to room $roomId}")
roomListService.subscribeToRooms(listOf(roomId.value))
}
subscribedRoomIds.add(roomId)
} catch (exception: Exception) {
Timber.e("Failed to subscribe to room $roomId")
}
}
}
}
suspend fun batchSubscribe(roomIds: List<RoomId>) = mutex.withLock {
withContext(dispatchers.io) {
try {
val roomIdsToSubscribeTo = roomIds.filterNot { isSubscribedTo(it) }
if (roomIdsToSubscribeTo.isNotEmpty()) {
Timber.d("Subscribing to rooms: $roomIds")
roomListService.subscribeToRooms(roomIdsToSubscribeTo.map { it.value })
subscribedRoomIds.addAll(roomIds)
}
} catch (cancellationException: CancellationException) {
throw cancellationException
} catch (exception: Exception) {
Timber.e(exception, "Failed to subscribe to rooms: $roomIds")
}
}
}
fun isSubscribedTo(roomId: RoomId): Boolean {
return subscribedRoomIds.contains(roomId)
}
}
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.room
import io.element.android.libraries.matrix.api.room.RoomType
import org.matrix.rustcomponents.sdk.RoomType as RustRoomType
fun RustRoomType.map(): RoomType {
return when (this) {
RustRoomType.Room -> RoomType.Room
RustRoomType.Space -> RoomType.Space
is RustRoomType.Custom -> RoomType.Other(this.value)
}
}
@@ -0,0 +1,334 @@
/*
* 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.impl.room
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScope
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.impl.room.draft.into
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
import io.element.android.libraries.matrix.impl.room.powerlevels.RoomPowerLevelsValuesMapper
import io.element.android.libraries.matrix.impl.room.tombstone.map
import io.element.android.libraries.matrix.impl.roomdirectory.map
import io.element.android.libraries.matrix.impl.timeline.toRustReceiptType
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.CallDeclineListener
import org.matrix.rustcomponents.sdk.RoomInfoListener
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import uniffi.matrix_sdk_base.EncryptionState
import org.matrix.rustcomponents.sdk.Room as InnerRoom
class RustBaseRoom(
override val sessionId: SessionId,
internal val deviceId: DeviceId,
internal val innerRoom: InnerRoom,
coroutineDispatchers: CoroutineDispatchers,
private val roomSyncSubscriber: RoomSyncSubscriber,
private val roomMembershipObserver: RoomMembershipObserver,
sessionCoroutineScope: CoroutineScope,
roomInfoMapper: RoomInfoMapper,
initialRoomInfo: RoomInfo,
) : BaseRoom {
override val roomId = RoomId(innerRoom.id())
// Create a dispatcher for all room methods...
private val roomDispatcher = coroutineDispatchers.io.limitedParallelism(32)
// ...except getMember methods as it could quickly fill the roomDispatcher...
private val roomMembersDispatcher = coroutineDispatchers.io.limitedParallelism(8)
internal val roomMemberListFetcher = RoomMemberListFetcher(innerRoom, roomMembersDispatcher)
override val membersStateFlow: StateFlow<RoomMembersState> = roomMemberListFetcher.membersFlow
override val roomCoroutineScope = sessionCoroutineScope.childScope(coroutineDispatchers.main, "RoomScope-$roomId")
override val roomInfoFlow: StateFlow<RoomInfo> = mxCallbackFlow {
innerRoom.subscribeToRoomInfoUpdates(object : RoomInfoListener {
override fun call(roomInfo: org.matrix.rustcomponents.sdk.RoomInfo) {
channel.trySend(roomInfoMapper.map(roomInfo))
}
})
}.stateIn(roomCoroutineScope, started = SharingStarted.Lazily, initialValue = initialRoomInfo)
override fun predecessorRoom(): PredecessorRoom? {
return innerRoom.predecessorRoom()?.map()
}
override suspend fun subscribeToSync() = roomSyncSubscriber.subscribe(roomId)
override suspend fun updateMembers() {
val useCache = membersStateFlow.value is RoomMembersState.Unknown
val source = if (useCache) {
RoomMemberListFetcher.Source.CACHE_AND_SERVER
} else {
RoomMemberListFetcher.Source.SERVER
}
roomMemberListFetcher.fetchRoomMembers(source = source)
}
override suspend fun getMembers(limit: Int) = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.members().use {
it.nextChunk(limit.toUInt()).orEmpty().map { roomMember ->
RoomMemberMapper.map(roomMember)
}
}
}
}
override suspend fun getUpdatedMember(userId: UserId): Result<RoomMember> = withContext(roomDispatcher) {
runCatchingExceptions {
RoomMemberMapper.map(innerRoom.member(userId.value))
}
}
override fun close() = destroy()
override fun destroy() {
innerRoom.destroy()
roomCoroutineScope.cancel()
}
override suspend fun userDisplayName(userId: UserId): Result<String?> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.memberDisplayName(userId.value)
}
}
override suspend fun userRole(userId: UserId): Result<RoomMember.Role> = withContext(roomDispatcher) {
runCatchingExceptions {
val powerLevel = roomInfoFlow.value.roomPowerLevels?.powerLevelOf(userId) ?: 0L
RoomMemberMapper.mapRole(
role = innerRoom.suggestedRoleForUser(userId.value),
powerLevel = powerLevel,
)
}
}
override suspend fun powerLevels(): Result<RoomPowerLevelsValues> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.getPowerLevels().use {
RoomPowerLevelsValuesMapper.map(it.values())
}
}
}
override suspend fun userAvatarUrl(userId: UserId): Result<String?> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.memberAvatarUrl(userId.value)
}
}
override suspend fun leave(): Result<Unit> = withContext(roomDispatcher) {
val membershipBeforeLeft = roomInfoFlow.value.currentUserMembership
runCatchingExceptions {
innerRoom.leave()
}.onSuccess {
roomMembershipObserver.notifyUserLeftRoom(
roomId = roomId,
isSpace = roomInfoFlow.value.isSpace,
membershipBeforeLeft = membershipBeforeLeft,
)
}
}
override suspend fun join(): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.join()
}
}
override suspend fun forget(): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.forget()
}
}
override suspend fun canUserInvite(userId: UserId): Result<Boolean> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.getPowerLevels().use { it.canUserInvite(userId.value) }
}
}
override suspend fun canUserKick(userId: UserId): Result<Boolean> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.getPowerLevels().use { it.canUserKick(userId.value) }
}
}
override suspend fun canUserBan(userId: UserId): Result<Boolean> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.getPowerLevels().use { it.canUserBan(userId.value) }
}
}
override suspend fun canUserRedactOwn(userId: UserId): Result<Boolean> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.getPowerLevels().use { it.canUserRedactOwn(userId.value) }
}
}
override suspend fun canUserRedactOther(userId: UserId): Result<Boolean> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.getPowerLevels().use { it.canUserRedactOther(userId.value) }
}
}
override suspend fun canUserSendState(userId: UserId, type: StateEventType): Result<Boolean> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.getPowerLevels().use { it.canUserSendState(userId.value, type.map()) }
}
}
override suspend fun canUserSendMessage(userId: UserId, type: MessageEventType): Result<Boolean> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.getPowerLevels().use { it.canUserSendMessage(userId.value, type.map()) }
}
}
override suspend fun canUserTriggerRoomNotification(userId: UserId): Result<Boolean> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.getPowerLevels().use { it.canUserTriggerRoomNotification(userId.value) }
}
}
override suspend fun canUserPinUnpin(userId: UserId): Result<Boolean> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.getPowerLevels().use { it.canUserPinUnpin(userId.value) }
}
}
override suspend fun clearEventCacheStorage(): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.clearEventCacheStorage()
}
}
override suspend fun setIsFavorite(isFavorite: Boolean): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.setIsFavourite(isFavorite, null)
}
}
override suspend fun markAsRead(receiptType: ReceiptType): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.markAsRead(receiptType.toRustReceiptType())
}
}
override suspend fun setUnreadFlag(isUnread: Boolean): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.setUnreadFlag(isUnread)
}
}
override suspend fun getPermalink(): Result<String> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.matrixToPermalink()
}
}
override suspend fun getPermalinkFor(eventId: EventId): Result<String> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.matrixToEventPermalink(eventId.value)
}
}
override suspend fun getRoomVisibility(): Result<RoomVisibility> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.getRoomVisibility().map()
}
}
override suspend fun getUpdatedIsEncrypted(): Result<Boolean> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.latestEncryptionState() == EncryptionState.ENCRYPTED
}
}
override suspend fun saveComposerDraft(composerDraft: ComposerDraft, threadRoot: ThreadId?): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
Timber.d("saveComposerDraft: $composerDraft into $roomId for thread root: $threadRoot")
innerRoom.saveComposerDraft(composerDraft.into(), threadRoot = threadRoot?.value)
}
}
override suspend fun loadComposerDraft(threadRoot: ThreadId?): Result<ComposerDraft?> = withContext(roomDispatcher) {
runCatchingExceptions {
Timber.d("loadComposerDraft for $roomId with thread root: $threadRoot")
innerRoom.loadComposerDraft(threadRoot?.value)?.into()
}
}
override suspend fun clearComposerDraft(threadRoot: ThreadId?): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
Timber.d("clearComposerDraft for $roomId with thread root: $threadRoot")
innerRoom.clearComposerDraft(threadRoot = threadRoot?.value)
}
}
override suspend fun reportRoom(reason: String?): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
Timber.d("reportRoom $roomId")
innerRoom.reportRoom(reason.orEmpty())
}
}
override suspend fun declineCall(notificationEventId: EventId): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.declineCall(notificationEventId.value)
}
}
override suspend fun subscribeToCallDecline(notificationEventId: EventId): Flow<UserId> = withContext(roomDispatcher) {
mxCallbackFlow {
innerRoom.subscribeToCallDeclineEvents(notificationEventId.value, object : CallDeclineListener {
override fun call(declinerUserId: String) {
trySend(UserId(declinerUserId))
}
})
}
}
override suspend fun threadRootIdForEvent(eventId: EventId): Result<ThreadId?> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.loadOrFetchEvent(eventId.value).use {
it.threadRootEventId()?.let(::ThreadId)
}
}
}
}
@@ -0,0 +1,209 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.room
import io.element.android.appconfig.TimelineConfig
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.awaitLoaded
import io.element.android.libraries.matrix.impl.room.preview.RoomPreviewInfoMapper
import io.element.android.libraries.matrix.impl.roomlist.roomOrNull
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.recordTransaction
import io.element.android.services.analyticsproviders.api.recordChildTransaction
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.DateDividerMode
import org.matrix.rustcomponents.sdk.Membership
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomInfo
import org.matrix.rustcomponents.sdk.TimelineConfiguration
import org.matrix.rustcomponents.sdk.TimelineFilter
import org.matrix.rustcomponents.sdk.TimelineFocus
import timber.log.Timber
import uniffi.matrix_sdk_ui.TimelineReadReceiptTracking
import java.util.concurrent.atomic.AtomicBoolean
import org.matrix.rustcomponents.sdk.RoomListService as InnerRoomListService
class RustRoomFactory(
private val sessionId: SessionId,
private val deviceId: DeviceId,
private val notificationSettingsService: NotificationSettingsService,
private val sessionCoroutineScope: CoroutineScope,
private val dispatchers: CoroutineDispatchers,
private val systemClock: SystemClock,
private val roomContentForwarder: RoomContentForwarder,
private val roomListService: RoomListService,
private val innerRoomListService: InnerRoomListService,
private val roomSyncSubscriber: RoomSyncSubscriber,
private val timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory,
private val featureFlagService: FeatureFlagService,
private val roomMembershipObserver: RoomMembershipObserver,
private val roomInfoMapper: RoomInfoMapper,
private val analyticsService: AnalyticsService,
) {
private val dispatcher = dispatchers.computation.limitedParallelism(1)
private val mutex = Mutex()
private val isDestroyed: AtomicBoolean = AtomicBoolean(false)
private val eventFilters = TimelineConfig.excludedEvents
.takeIf { it.isNotEmpty() }
?.let { listStateEventType ->
timelineEventTypeFilterFactory.create(listStateEventType)
}
suspend fun destroy() {
withContext(NonCancellable + dispatcher) {
mutex.withLock {
Timber.d("Destroying room factory")
isDestroyed.set(true)
}
}
}
suspend fun getBaseRoom(roomId: RoomId): RustBaseRoom? = withContext(dispatcher) {
mutex.withLock {
if (isDestroyed.get()) {
Timber.d("Room factory is destroyed, returning null for $roomId")
return@withContext null
}
val room = awaitRoomInRoomList(roomId) ?: return@withContext null
getBaseRoom(sdkRoom = room, roomInfo = room.roomInfo())
}
}
private fun getBaseRoom(sdkRoom: Room, roomInfo: RoomInfo) = RustBaseRoom(
sessionId = sessionId,
deviceId = deviceId,
innerRoom = sdkRoom,
coroutineDispatchers = dispatchers,
roomSyncSubscriber = roomSyncSubscriber,
roomMembershipObserver = roomMembershipObserver,
roomInfoMapper = roomInfoMapper,
initialRoomInfo = roomInfoMapper.map(roomInfo),
sessionCoroutineScope = sessionCoroutineScope,
)
suspend fun getJoinedRoomOrPreview(roomId: RoomId, serverNames: List<String>): GetRoomResult? = withContext(dispatcher) {
mutex.withLock {
if (isDestroyed.get()) {
Timber.d("Room factory is destroyed, returning null for $roomId")
return@withContext null
}
val sdkRoom = awaitRoomInRoomList(roomId) ?: return@withLock null
val roomInfo = sdkRoom.roomInfo()
val parentTransaction = analyticsService.getLongRunningTransaction(AnalyticsLongRunningTransaction.OpenRoom)
if (roomInfo.membership == Membership.JOINED) {
analyticsService.recordTransaction(
name = "Get joined room",
operation = "RustRoomFactory.getJoinedRoomOrPreview",
parentTransaction = parentTransaction,
) { transaction ->
val hideThreadedEvents = featureFlagService.isFeatureEnabled(FeatureFlags.Threads)
// Init the live timeline in the SDK from the Room
val timeline = transaction.recordChildTransaction(
operation = "sdkRoom.timelineWithConfiguration",
description = "Get timeline from the SDK",
) {
sdkRoom.timelineWithConfiguration(
TimelineConfiguration(
focus = TimelineFocus.Live(hideThreadedEvents = hideThreadedEvents),
filter = eventFilters?.let(TimelineFilter::EventTypeFilter) ?: TimelineFilter.All,
internalIdPrefix = "live",
dateDividerMode = DateDividerMode.DAILY,
trackReadReceipts = TimelineReadReceiptTracking.ALL_EVENTS,
reportUtds = true,
)
)
}
GetRoomResult.Joined(
JoinedRustRoom(
baseRoom = getBaseRoom(sdkRoom, roomInfo),
notificationSettingsService = notificationSettingsService,
roomContentForwarder = roomContentForwarder,
liveInnerTimeline = timeline,
coroutineDispatchers = dispatchers,
systemClock = systemClock,
featureFlagService = featureFlagService,
)
)
}
} else {
analyticsService.recordTransaction(
name = "Get preview of room",
operation = "RustRoomFactory.getJoinedRoomOrPreview",
parentTransaction = parentTransaction,
) {
val preview = try {
sdkRoom.previewRoom(via = serverNames)
} catch (e: Exception) {
Timber.e(e, "Failed to get room preview for $roomId")
return@recordTransaction null
}
GetRoomResult.NotJoined(
NotJoinedRustRoom(
sessionId = sessionId,
localRoom = getBaseRoom(sdkRoom, roomInfo),
previewInfo = RoomPreviewInfoMapper.map(preview.info()),
)
)
}
}
}
}
/**
* Get the Rust room for a room, retrying after the room list is loaded if necessary.
*/
private suspend fun awaitRoomInRoomList(roomId: RoomId): Room? {
var sdkRoom = innerRoomListService.roomOrNull(roomId.value)
if (sdkRoom == null) {
// ... otherwise, lets wait for the SS to load all rooms and check again.
roomListService.allRooms.awaitLoaded()
sdkRoom = innerRoomListService.roomOrNull(roomId.value)
}
if (sdkRoom == null) {
Timber.d("Room not found for $roomId")
return null
}
return sdkRoom
}
}
sealed interface GetRoomResult {
data class Joined(val joinedRoom: JoinedRoom) : GetRoomResult
data class NotJoined(val notJoinedRoom: NotJoinedRustRoom) : GetRoomResult
val room: BaseRoom?
get() = when (this) {
is Joined -> joinedRoom
is NotJoined -> notJoinedRoom.localRoom
}
}
@@ -0,0 +1,62 @@
/*
* 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.impl.room
import io.element.android.libraries.matrix.api.room.StateEventType
import org.matrix.rustcomponents.sdk.StateEventType as RustStateEventType
fun StateEventType.map(): RustStateEventType = when (this) {
StateEventType.POLICY_RULE_ROOM -> RustStateEventType.POLICY_RULE_ROOM
StateEventType.POLICY_RULE_SERVER -> RustStateEventType.POLICY_RULE_SERVER
StateEventType.POLICY_RULE_USER -> RustStateEventType.POLICY_RULE_USER
StateEventType.CALL_MEMBER -> RustStateEventType.CALL_MEMBER
StateEventType.ROOM_ALIASES -> RustStateEventType.ROOM_ALIASES
StateEventType.ROOM_AVATAR -> RustStateEventType.ROOM_AVATAR
StateEventType.ROOM_CANONICAL_ALIAS -> RustStateEventType.ROOM_CANONICAL_ALIAS
StateEventType.ROOM_CREATE -> RustStateEventType.ROOM_CREATE
StateEventType.ROOM_ENCRYPTION -> RustStateEventType.ROOM_ENCRYPTION
StateEventType.ROOM_GUEST_ACCESS -> RustStateEventType.ROOM_GUEST_ACCESS
StateEventType.ROOM_HISTORY_VISIBILITY -> RustStateEventType.ROOM_HISTORY_VISIBILITY
StateEventType.ROOM_JOIN_RULES -> RustStateEventType.ROOM_JOIN_RULES
StateEventType.ROOM_MEMBER_EVENT -> RustStateEventType.ROOM_MEMBER_EVENT
StateEventType.ROOM_NAME -> RustStateEventType.ROOM_NAME
StateEventType.ROOM_PINNED_EVENTS -> RustStateEventType.ROOM_PINNED_EVENTS
StateEventType.ROOM_POWER_LEVELS -> RustStateEventType.ROOM_POWER_LEVELS
StateEventType.ROOM_SERVER_ACL -> RustStateEventType.ROOM_SERVER_ACL
StateEventType.ROOM_THIRD_PARTY_INVITE -> RustStateEventType.ROOM_THIRD_PARTY_INVITE
StateEventType.ROOM_TOMBSTONE -> RustStateEventType.ROOM_TOMBSTONE
StateEventType.ROOM_TOPIC -> RustStateEventType.ROOM_TOPIC
StateEventType.SPACE_CHILD -> RustStateEventType.SPACE_CHILD
StateEventType.SPACE_PARENT -> RustStateEventType.SPACE_PARENT
}
fun RustStateEventType.map(): StateEventType = when (this) {
RustStateEventType.POLICY_RULE_ROOM -> StateEventType.POLICY_RULE_ROOM
RustStateEventType.POLICY_RULE_SERVER -> StateEventType.POLICY_RULE_SERVER
RustStateEventType.POLICY_RULE_USER -> StateEventType.POLICY_RULE_USER
RustStateEventType.CALL_MEMBER -> StateEventType.CALL_MEMBER
RustStateEventType.ROOM_ALIASES -> StateEventType.ROOM_ALIASES
RustStateEventType.ROOM_AVATAR -> StateEventType.ROOM_AVATAR
RustStateEventType.ROOM_CANONICAL_ALIAS -> StateEventType.ROOM_CANONICAL_ALIAS
RustStateEventType.ROOM_CREATE -> StateEventType.ROOM_CREATE
RustStateEventType.ROOM_ENCRYPTION -> StateEventType.ROOM_ENCRYPTION
RustStateEventType.ROOM_GUEST_ACCESS -> StateEventType.ROOM_GUEST_ACCESS
RustStateEventType.ROOM_HISTORY_VISIBILITY -> StateEventType.ROOM_HISTORY_VISIBILITY
RustStateEventType.ROOM_JOIN_RULES -> StateEventType.ROOM_JOIN_RULES
RustStateEventType.ROOM_MEMBER_EVENT -> StateEventType.ROOM_MEMBER_EVENT
RustStateEventType.ROOM_NAME -> StateEventType.ROOM_NAME
RustStateEventType.ROOM_PINNED_EVENTS -> StateEventType.ROOM_PINNED_EVENTS
RustStateEventType.ROOM_POWER_LEVELS -> StateEventType.ROOM_POWER_LEVELS
RustStateEventType.ROOM_SERVER_ACL -> StateEventType.ROOM_SERVER_ACL
RustStateEventType.ROOM_THIRD_PARTY_INVITE -> StateEventType.ROOM_THIRD_PARTY_INVITE
RustStateEventType.ROOM_TOMBSTONE -> StateEventType.ROOM_TOMBSTONE
RustStateEventType.ROOM_TOPIC -> StateEventType.ROOM_TOPIC
RustStateEventType.SPACE_CHILD -> StateEventType.SPACE_CHILD
RustStateEventType.SPACE_PARENT -> StateEventType.SPACE_PARENT
}
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.room
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.api.room.StateEventType
import org.matrix.rustcomponents.sdk.FilterTimelineEventType
import org.matrix.rustcomponents.sdk.TimelineEventTypeFilter
interface TimelineEventTypeFilterFactory {
fun create(listStateEventType: List<StateEventType>): TimelineEventTypeFilter
}
@ContributesBinding(AppScope::class)
class RustTimelineEventTypeFilterFactory : TimelineEventTypeFilterFactory {
override fun create(listStateEventType: List<StateEventType>): TimelineEventTypeFilter {
return TimelineEventTypeFilter.exclude(
listStateEventType.map { stateEventType ->
FilterTimelineEventType.State(stateEventType.map())
}
)
}
}
@@ -0,0 +1,25 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.room.alias
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
@ContributesBinding(AppScope::class)
class DefaultRoomAliasHelper : RoomAliasHelper {
override fun roomAliasNameFromRoomDisplayName(name: String): String {
return org.matrix.rustcomponents.sdk.roomAliasNameFromRoomDisplayName(name)
}
override fun isRoomAliasValid(roomAlias: RoomAlias): Boolean {
return org.matrix.rustcomponents.sdk.isRoomAliasFormatValid(roomAlias.value)
}
}
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.room.draft
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.draft.ComposerDraft
import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType
import org.matrix.rustcomponents.sdk.ComposerDraft as RustComposerDraft
import org.matrix.rustcomponents.sdk.ComposerDraftType as RustComposerDraftType
internal fun ComposerDraft.into(): RustComposerDraft {
return RustComposerDraft(
plainText = plainText,
htmlText = htmlText,
draftType = draftType.into(),
// TODO add media attachments to the draft
attachments = emptyList(),
)
}
internal fun RustComposerDraft.into(): ComposerDraft {
return ComposerDraft(
plainText = plainText,
htmlText = htmlText,
draftType = draftType.into()
)
}
private fun RustComposerDraftType.into(): ComposerDraftType {
return when (this) {
RustComposerDraftType.NewMessage -> ComposerDraftType.NewMessage
is RustComposerDraftType.Reply -> ComposerDraftType.Reply(EventId(eventId))
is RustComposerDraftType.Edit -> ComposerDraftType.Edit(EventId(eventId))
}
}
private fun ComposerDraftType.into(): RustComposerDraftType {
return when (this) {
ComposerDraftType.NewMessage -> RustComposerDraftType.NewMessage
is ComposerDraftType.Reply -> RustComposerDraftType.Reply(eventId.value)
is ComposerDraftType.Edit -> RustComposerDraftType.Edit(eventId.value)
}
}
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.impl.room.history
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import org.matrix.rustcomponents.sdk.RoomHistoryVisibility as RustRoomHistoryVisibility
fun RoomHistoryVisibility.map(): RustRoomHistoryVisibility {
return when (this) {
RoomHistoryVisibility.WorldReadable -> RustRoomHistoryVisibility.WorldReadable
RoomHistoryVisibility.Invited -> RustRoomHistoryVisibility.Invited
RoomHistoryVisibility.Joined -> RustRoomHistoryVisibility.Joined
RoomHistoryVisibility.Shared -> RustRoomHistoryVisibility.Shared
is RoomHistoryVisibility.Custom -> RustRoomHistoryVisibility.Custom(value)
}
}
fun RustRoomHistoryVisibility.map(): RoomHistoryVisibility {
return when (this) {
RustRoomHistoryVisibility.WorldReadable -> RoomHistoryVisibility.WorldReadable
RustRoomHistoryVisibility.Invited -> RoomHistoryVisibility.Invited
RustRoomHistoryVisibility.Joined -> RoomHistoryVisibility.Joined
RustRoomHistoryVisibility.Shared -> RoomHistoryVisibility.Shared
is RustRoomHistoryVisibility.Custom -> RoomHistoryVisibility.Custom(value)
}
}
@@ -0,0 +1,27 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.room.join
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.join.AllowRule
import org.matrix.rustcomponents.sdk.AllowRule as RustAllowRule
fun RustAllowRule.map(): AllowRule {
return when (this) {
is RustAllowRule.RoomMembership -> AllowRule.RoomMembership(RoomId(roomId))
is RustAllowRule.Custom -> AllowRule.Custom(json)
}
}
fun AllowRule.map(): RustAllowRule {
return when (this) {
is AllowRule.RoomMembership -> RustAllowRule.RoomMembership(roomId.value)
is AllowRule.Custom -> RustAllowRule.Custom(json)
}
}
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.room.join
import dev.zacsweers.metro.ContributesBinding
import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.exception.ClientException
import io.element.android.libraries.matrix.api.exception.ErrorKind
import io.element.android.libraries.matrix.api.room.join.JoinRoom
import io.element.android.libraries.matrix.impl.analytics.toAnalyticsJoinedRoom
import io.element.android.services.analytics.api.AnalyticsService
@ContributesBinding(SessionScope::class)
class DefaultJoinRoom(
private val client: MatrixClient,
private val analyticsService: AnalyticsService,
) : JoinRoom {
override suspend fun invoke(
roomIdOrAlias: RoomIdOrAlias,
serverNames: List<String>,
trigger: JoinedRoom.Trigger
): Result<Unit> {
return when (roomIdOrAlias) {
is RoomIdOrAlias.Id -> {
if (serverNames.isEmpty()) {
client.joinRoom(roomIdOrAlias.roomId)
} else {
client.joinRoomByIdOrAlias(roomIdOrAlias, serverNames)
}
}
is RoomIdOrAlias.Alias -> {
client.joinRoomByIdOrAlias(roomIdOrAlias, serverNames = emptyList())
}
}.onSuccess { roomInfo ->
if (roomInfo != null) {
analyticsService.capture(roomInfo.toAnalyticsJoinedRoom(trigger))
}
}.mapFailure {
if (it is ClientException.MatrixApi) {
when (it.kind) {
ErrorKind.Forbidden -> JoinRoom.Failures.UnauthorizedJoin
else -> it
}
} else {
it
}
}.map { }
}
}
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.room.join
import io.element.android.libraries.matrix.api.room.join.JoinRule
import kotlinx.collections.immutable.toImmutableList
import org.matrix.rustcomponents.sdk.JoinRule as RustJoinRule
fun RustJoinRule.map(): JoinRule {
return when (this) {
RustJoinRule.Public -> JoinRule.Public
RustJoinRule.Private -> JoinRule.Private
RustJoinRule.Knock -> JoinRule.Knock
RustJoinRule.Invite -> JoinRule.Invite
is RustJoinRule.Restricted -> JoinRule.Restricted(rules.map { it.map() }.toImmutableList())
is RustJoinRule.Custom -> JoinRule.Custom(repr)
is RustJoinRule.KnockRestricted -> JoinRule.KnockRestricted(rules.map { it.map() }.toImmutableList())
}
}
fun JoinRule.map(): RustJoinRule {
return when (this) {
JoinRule.Public -> RustJoinRule.Public
JoinRule.Private -> RustJoinRule.Private
JoinRule.Knock -> RustJoinRule.Knock
JoinRule.Invite -> RustJoinRule.Invite
is JoinRule.Restricted -> RustJoinRule.Restricted(rules.map { it.map() })
is JoinRule.Custom -> RustJoinRule.Custom(value)
is JoinRule.KnockRestricted -> RustJoinRule.KnockRestricted(rules.map { it.map() })
}
}
@@ -0,0 +1,43 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.room.knock
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.knock.KnockRequest
import org.matrix.rustcomponents.sdk.KnockRequest as InnerKnockRequest
class RustKnockRequest(
private val inner: InnerKnockRequest,
) : KnockRequest {
override val eventId: EventId = EventId(inner.eventId)
override val userId: UserId = UserId(inner.userId)
override val displayName: String? = inner.displayName
override val avatarUrl: String? = inner.avatarUrl
override val reason: String? = inner.reason
override val timestamp: Long? = inner.timestamp?.toLong()
override val isSeen: Boolean = inner.isSeen
override suspend fun accept(): Result<Unit> = runCatchingExceptions {
inner.actions.accept()
}
override suspend fun decline(reason: String?): Result<Unit> = runCatchingExceptions {
inner.actions.decline(reason)
}
override suspend fun declineAndBan(reason: String?): Result<Unit> = runCatchingExceptions {
inner.actions.declineAndBan(reason)
}
override suspend fun markAsSeen(): Result<Unit> = runCatchingExceptions {
inner.actions.markAsSeen()
}
}
@@ -0,0 +1,16 @@
/*
* 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.impl.room.location
import io.element.android.libraries.matrix.api.room.location.AssetType
fun AssetType.toInner(): org.matrix.rustcomponents.sdk.AssetType = when (this) {
AssetType.SENDER -> org.matrix.rustcomponents.sdk.AssetType.SENDER
AssetType.PIN -> org.matrix.rustcomponents.sdk.AssetType.PIN
}
@@ -0,0 +1,134 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.room.member
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.roomMembers
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.RoomInterface
import org.matrix.rustcomponents.sdk.RoomMembersIterator
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import kotlin.coroutines.cancellation.CancellationException
import kotlin.coroutines.coroutineContext
/**
* This class fetches the room members for a given room in a 'paginated' way, and taking into account previous cached values.
*/
internal class RoomMemberListFetcher(
private val room: RoomInterface,
private val dispatcher: CoroutineDispatcher,
private val pageSize: Int = 10_000,
) {
enum class Source {
CACHE,
CACHE_AND_SERVER,
SERVER,
}
private val updatedRoomMemberMutex = Mutex()
private val roomId = room.id()
private val _membersFlow = MutableStateFlow<RoomMembersState>(RoomMembersState.Unknown)
val membersFlow: StateFlow<RoomMembersState> = _membersFlow
/**
* Fetches the room members for the given room.
* It will emit the cached members first, and then the updated members in batches of [pageSize] items, through [membersFlow].
* @param source Where we should load the members from. Defaults to [Source.CACHE_AND_SERVER].
*/
suspend fun fetchRoomMembers(source: Source = Source.CACHE_AND_SERVER) {
if (updatedRoomMemberMutex.isLocked) {
Timber.i("Room members are already being updated for room $roomId")
return
}
updatedRoomMemberMutex.withLock {
withContext(dispatcher) {
_membersFlow.run {
when (source) {
Source.CACHE -> {
fetchCachedRoomMembers(asPendingState = false)
}
Source.CACHE_AND_SERVER -> {
fetchCachedRoomMembers(asPendingState = true)
fetchRemoteRoomMembers()
}
Source.SERVER -> {
fetchRemoteRoomMembers()
}
}
}
}
}
}
private suspend fun MutableStateFlow<RoomMembersState>.fetchCachedRoomMembers(asPendingState: Boolean = true) {
Timber.i("Loading cached members for room $roomId")
try {
// Send current member list with pending state to notify the UI that we are loading new members
value = pendingWithCurrentMembers()
val members = parseAndEmitMembers(room.membersNoSync())
val newState = if (asPendingState) {
RoomMembersState.Pending(prevRoomMembers = members)
} else {
RoomMembersState.Ready(members)
}
value = newState
} catch (exception: CancellationException) {
Timber.d("Cancelled loading cached members for room $roomId")
throw exception
} catch (exception: Exception) {
Timber.e(exception, "Failed to load cached members for room $roomId")
value = RoomMembersState.Error(exception, _membersFlow.value.roomMembers()?.toImmutableList())
}
}
private suspend fun MutableStateFlow<RoomMembersState>.fetchRemoteRoomMembers() {
try {
// Send current member list with pending state to notify the UI that we are loading new members
value = pendingWithCurrentMembers()
// Start loading new members
value = RoomMembersState.Ready(parseAndEmitMembers(room.members()))
} catch (exception: CancellationException) {
Timber.d("Cancelled loading updated members for room $roomId")
throw exception
} catch (exception: Exception) {
Timber.e(exception, "Failed to load updated members for room $roomId")
value = RoomMembersState.Error(exception, _membersFlow.value.roomMembers()?.toImmutableList())
}
}
private suspend fun parseAndEmitMembers(roomMembersIterator: RoomMembersIterator): ImmutableList<RoomMember> {
return roomMembersIterator.use { iterator ->
val results = buildList(capacity = roomMembersIterator.len().toInt()) {
while (true) {
// Loading the whole membersIterator as a stop-gap measure.
// We should probably implement some sort of paging in the future.
coroutineContext.ensureActive()
val chunk = iterator.nextChunk(pageSize.toUInt())
// Load next chunk. If null (no more items), exit the loop
val members = chunk?.map(RoomMemberMapper::map) ?: break
addAll(members)
Timber.i("Loaded first $size members for room $roomId")
}
}
results.toImmutableList()
}
}
private fun pendingWithCurrentMembers() = RoomMembersState.Pending(_membersFlow.value.roomMembers().orEmpty().toImmutableList())
}
@@ -0,0 +1,60 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.room.member
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.impl.room.powerlevels.into
import uniffi.matrix_sdk.RoomMemberRole
import org.matrix.rustcomponents.sdk.MembershipState as RustMembershipState
import org.matrix.rustcomponents.sdk.RoomMember as RustRoomMember
object RoomMemberMapper {
fun map(roomMember: RustRoomMember): RoomMember {
val powerLevel = roomMember.powerLevel.into()
return RoomMember(
userId = UserId(roomMember.userId),
displayName = roomMember.displayName,
avatarUrl = roomMember.avatarUrl,
membership = mapMembership(roomMember.membership),
isNameAmbiguous = roomMember.isNameAmbiguous,
powerLevel = powerLevel,
isIgnored = roomMember.isIgnored,
role = mapRole(roomMember.suggestedRoleForPowerLevel, powerLevel),
membershipChangeReason = roomMember.membershipChangeReason
)
}
fun mapRole(role: RoomMemberRole, powerLevel: Long?): RoomMember.Role =
when (role) {
RoomMemberRole.CREATOR -> RoomMember.Role.Owner(isCreator = true)
RoomMemberRole.ADMINISTRATOR -> {
val superAdmin = RoomMember.Role.Owner(isCreator = false)
val powerLevelOrDefault = powerLevel ?: 0L
if (powerLevelOrDefault >= superAdmin.powerLevel) {
superAdmin
} else {
RoomMember.Role.Admin
}
}
RoomMemberRole.MODERATOR -> RoomMember.Role.Moderator
RoomMemberRole.USER -> RoomMember.Role.User
}
fun mapMembership(membershipState: RustMembershipState): RoomMembershipState =
when (membershipState) {
RustMembershipState.Ban -> RoomMembershipState.BAN
RustMembershipState.Invite -> RoomMembershipState.INVITE
RustMembershipState.Join -> RoomMembershipState.JOIN
RustMembershipState.Knock -> RoomMembershipState.KNOCK
RustMembershipState.Leave -> RoomMembershipState.LEAVE
is RustMembershipState.Custom -> TODO()
}
}
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.room.powerlevels
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import org.matrix.rustcomponents.sdk.PowerLevel
import org.matrix.rustcomponents.sdk.RoomPowerLevelsValues as RustRoomPowerLevelsValues
object RoomPowerLevelsValuesMapper {
fun map(values: RustRoomPowerLevelsValues): RoomPowerLevelsValues {
return RoomPowerLevelsValues(
ban = values.ban,
invite = values.invite,
kick = values.kick,
sendEvents = values.eventsDefault,
redactEvents = values.redact,
roomName = values.roomName,
roomAvatar = values.roomAvatar,
roomTopic = values.roomTopic,
spaceChild = values.spaceChild,
)
}
}
fun PowerLevel.into(): Long = when (this) {
PowerLevel.Infinite -> RoomMember.Role.Owner(isCreator = true).powerLevel
is PowerLevel.Value -> this.value
}
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.room.preview
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.preview.RoomPreviewInfo
import io.element.android.libraries.matrix.impl.room.join.map
import io.element.android.libraries.matrix.impl.room.map
import org.matrix.rustcomponents.sdk.RoomPreviewInfo as RustRoomPreviewInfo
object RoomPreviewInfoMapper {
fun map(info: RustRoomPreviewInfo): RoomPreviewInfo {
return RoomPreviewInfo(
roomId = RoomId(info.roomId),
canonicalAlias = info.canonicalAlias?.let(::RoomAlias),
name = info.name,
topic = info.topic,
avatarUrl = info.avatarUrl,
numberOfJoinedMembers = info.numJoinedMembers.toLong(),
roomType = info.roomType.map(),
isHistoryWorldReadable = info.isHistoryWorldReadable.orFalse(),
membership = info.membership?.map(),
joinRule = info.joinRule?.map(),
)
}
}
@@ -0,0 +1,17 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.impl.room.tombstone
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom
import org.matrix.rustcomponents.sdk.PredecessorRoom as RustPredecessorRoom
fun RustPredecessorRoom.map(): PredecessorRoom {
return PredecessorRoom(roomId = RoomId(roomId))
}
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 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.impl.room.tombstone
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import org.matrix.rustcomponents.sdk.SuccessorRoom as RustSuccessorRoom
fun RustSuccessorRoom.map(): SuccessorRoom {
return SuccessorRoom(
roomId = RoomId(roomId),
reason = reason
)
}
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.roomdirectory
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.roomdirectory.RoomDescription
import org.matrix.rustcomponents.sdk.PublicRoomJoinRule
import org.matrix.rustcomponents.sdk.RoomDescription as RustRoomDescription
class RoomDescriptionMapper {
fun map(roomDescription: RustRoomDescription): RoomDescription {
return RoomDescription(
roomId = RoomId(roomDescription.roomId),
name = roomDescription.name,
topic = roomDescription.topic,
avatarUrl = roomDescription.avatarUrl,
alias = roomDescription.alias?.let(::RoomAlias),
joinRule = roomDescription.joinRule.map(),
isWorldReadable = roomDescription.isWorldReadable,
numberOfMembers = roomDescription.joinedMembers.toLong(),
)
}
}
internal fun PublicRoomJoinRule?.map(): RoomDescription.JoinRule {
return when (this) {
PublicRoomJoinRule.PUBLIC -> RoomDescription.JoinRule.PUBLIC
PublicRoomJoinRule.KNOCK -> RoomDescription.JoinRule.KNOCK
PublicRoomJoinRule.RESTRICTED -> RoomDescription.JoinRule.RESTRICTED
PublicRoomJoinRule.KNOCK_RESTRICTED -> RoomDescription.JoinRule.KNOCK_RESTRICTED
PublicRoomJoinRule.INVITE -> RoomDescription.JoinRule.INVITE
null -> RoomDescription.JoinRule.UNKNOWN
}
}
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.impl.roomdirectory
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import org.matrix.rustcomponents.sdk.RoomDirectorySearch
import org.matrix.rustcomponents.sdk.RoomDirectorySearchEntriesListener
import org.matrix.rustcomponents.sdk.RoomDirectorySearchEntryUpdate
import timber.log.Timber
internal fun RoomDirectorySearch.resultsFlow(): Flow<List<RoomDirectorySearchEntryUpdate>> =
callbackFlow {
val listener = object : RoomDirectorySearchEntriesListener {
override fun onUpdate(roomEntriesUpdate: List<RoomDirectorySearchEntryUpdate>) {
trySendBlocking(roomEntriesUpdate)
}
}
val result = results(listener)
awaitClose {
result.cancelAndDestroy()
}
}.catch {
Timber.d(it, "timelineDiffFlow() failed")
}.buffer(Channel.UNLIMITED)

Some files were not shown because too many files have changed in this diff Show More