First Commit
This commit is contained in:
@@ -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>
|
||||
+24
@@ -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()
|
||||
}
|
||||
}
|
||||
+127
@@ -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")
|
||||
}
|
||||
}
|
||||
+769
@@ -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,
|
||||
)
|
||||
)
|
||||
+201
@@ -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,
|
||||
)
|
||||
+20
@@ -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()
|
||||
}
|
||||
+41
@@ -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
|
||||
)
|
||||
}
|
||||
+52
@@ -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)
|
||||
}
|
||||
}
|
||||
+38
@@ -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)
|
||||
}
|
||||
}
|
||||
+20
@@ -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(),
|
||||
)
|
||||
}
|
||||
+31
@@ -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,
|
||||
)
|
||||
}
|
||||
+20
@@ -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)
|
||||
}
|
||||
}
|
||||
+38
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+358
@@ -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
|
||||
}
|
||||
}
|
||||
+50
@@ -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
|
||||
}
|
||||
}
|
||||
+22
@@ -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
|
||||
}
|
||||
}
|
||||
+23
@@ -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)) }
|
||||
}
|
||||
}
|
||||
+20
@@ -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()
|
||||
}
|
||||
}
|
||||
+77
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -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>
|
||||
}
|
||||
+23
@@ -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)
|
||||
}
|
||||
+22
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -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
|
||||
}
|
||||
}
|
||||
+93
@@ -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
|
||||
}
|
||||
}
|
||||
+26
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -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)
|
||||
}
|
||||
+36
@@ -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")
|
||||
)
|
||||
}
|
||||
}
|
||||
+23
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+272
@@ -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()
|
||||
}
|
||||
}
|
||||
+66
@@ -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()
|
||||
}
|
||||
+28
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -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")
|
||||
}
|
||||
}
|
||||
+64
@@ -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)
|
||||
}
|
||||
}
|
||||
+25
@@ -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)
|
||||
}
|
||||
}
|
||||
+17
@@ -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?
|
||||
}
|
||||
+19
@@ -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
|
||||
}
|
||||
+66
@@ -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,
|
||||
)
|
||||
+19
@@ -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,
|
||||
)
|
||||
+41
@@ -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() }
|
||||
+26
@@ -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,
|
||||
)
|
||||
+26
@@ -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
|
||||
)
|
||||
+33
@@ -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
|
||||
)
|
||||
+17
@@ -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())
|
||||
}
|
||||
+35
@@ -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() }
|
||||
}
|
||||
}
|
||||
+26
@@ -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()
|
||||
}
|
||||
}
|
||||
+97
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
@@ -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
|
||||
}
|
||||
}
|
||||
+26
@@ -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()
|
||||
)
|
||||
+36
@@ -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
|
||||
)
|
||||
+36
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
@@ -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()
|
||||
}
|
||||
+95
@@ -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()
|
||||
}
|
||||
}
|
||||
+113
@@ -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
|
||||
}
|
||||
+36
@@ -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
|
||||
}
|
||||
}
|
||||
+147
@@ -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()
|
||||
}
|
||||
}
|
||||
+21
@@ -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
|
||||
}
|
||||
}
|
||||
+29
@@ -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),
|
||||
)
|
||||
}
|
||||
+29
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+62
@@ -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/"
|
||||
)
|
||||
}
|
||||
}
|
||||
+41
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+86
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+17
@@ -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,
|
||||
)
|
||||
+22
@@ -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
|
||||
}
|
||||
+49
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -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?
|
||||
}
|
||||
+66
@@ -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
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+509
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+18
@@ -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)
|
||||
}
|
||||
+66
@@ -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)
|
||||
}
|
||||
+35
@@ -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()
|
||||
}
|
||||
}
|
||||
+73
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -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()
|
||||
}
|
||||
+109
@@ -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()
|
||||
)
|
||||
}
|
||||
+63
@@ -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)
|
||||
}
|
||||
}
|
||||
+20
@@ -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)
|
||||
}
|
||||
}
|
||||
+334
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+209
@@ -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
|
||||
}
|
||||
}
|
||||
+62
@@ -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
|
||||
}
|
||||
+30
@@ -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())
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
+25
@@ -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)
|
||||
}
|
||||
}
|
||||
+49
@@ -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)
|
||||
}
|
||||
}
|
||||
+32
@@ -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)
|
||||
}
|
||||
}
|
||||
+27
@@ -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)
|
||||
}
|
||||
}
|
||||
+59
@@ -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 { }
|
||||
}
|
||||
}
|
||||
+37
@@ -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() })
|
||||
}
|
||||
}
|
||||
+43
@@ -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()
|
||||
}
|
||||
}
|
||||
+16
@@ -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
|
||||
}
|
||||
+134
@@ -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())
|
||||
}
|
||||
+60
@@ -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()
|
||||
}
|
||||
}
|
||||
+35
@@ -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
|
||||
}
|
||||
+34
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+17
@@ -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))
|
||||
}
|
||||
+20
@@ -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
|
||||
)
|
||||
}
|
||||
+41
@@ -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
|
||||
}
|
||||
}
|
||||
+37
@@ -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
Reference in New Issue
Block a user