Merge pull request #7591 from element-hq/bma/iterateOnClientCreation

Iterate on client creation
This commit is contained in:
Benoit Marty
2026-09-11 13:22:42 +02:00
committed by GitHub
13 changed files with 355 additions and 77 deletions
@@ -13,6 +13,23 @@ import io.element.android.libraries.matrix.api.core.SessionId
/** /**
* A hook that can be used to customize the [MatrixClientBuilder] for enterprise features. * A hook that can be used to customize the [MatrixClientBuilder] for enterprise features.
*/ */
fun interface ClientBuilderEnterpriseHook { interface ClientBuilderEnterpriseHook {
suspend operator fun invoke(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder /**
* Customize the [MatrixClientBuilder] for enterprise features.
* This method is invoked everytime a new [MatrixClientBuilder] is created.
*
* @param clientBuilder The [MatrixClientBuilder] to customize.
* @return The customized [MatrixClientBuilder].
*/
suspend fun beforeClientCreation(clientBuilder: MatrixClientBuilder): MatrixClientBuilder
/**
* Customize the [MatrixClientBuilder] for enterprise features.
* This method is invoked as well as the other method when a new [MatrixClientBuilder] is created to build a client for a specific session.
*
* @param clientBuilder The [MatrixClientBuilder] to customize.
* @param sessionId The [SessionId] for which the [MatrixClientBuilder] is being created.
* @return The customized [MatrixClientBuilder].
*/
suspend fun beforeClientCreationWithSession(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder
} }
@@ -18,7 +18,12 @@ import io.element.android.libraries.matrix.api.core.SessionId
*/ */
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultClientBuilderEnterpriseHook : ClientBuilderEnterpriseHook { class DefaultClientBuilderEnterpriseHook : ClientBuilderEnterpriseHook {
override suspend fun invoke(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder { override suspend fun beforeClientCreation(clientBuilder: MatrixClientBuilder): MatrixClientBuilder {
// No modification
return clientBuilder
}
override suspend fun beforeClientCreationWithSession(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder {
// No modification // No modification
return clientBuilder return clientBuilder
} }
@@ -0,0 +1,25 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.enterprise.test
import io.element.android.features.enterprise.api.ClientBuilderEnterpriseHook
import io.element.android.libraries.matrix.api.MatrixClientBuilder
import io.element.android.libraries.matrix.api.core.SessionId
class FakeClientBuilderEnterpriseHook(
private val beforeClientCreationResult: (MatrixClientBuilder) -> MatrixClientBuilder = { it },
private val beforeClientCreationWithSessionResult: (MatrixClientBuilder, SessionId) -> MatrixClientBuilder = { clientBuilder, _ -> clientBuilder },
) : ClientBuilderEnterpriseHook {
override suspend fun beforeClientCreation(clientBuilder: MatrixClientBuilder): MatrixClientBuilder {
return beforeClientCreationResult(clientBuilder)
}
override suspend fun beforeClientCreationWithSession(clientBuilder: MatrixClientBuilder, sessionId: SessionId): MatrixClientBuilder {
return beforeClientCreationWithSessionResult(clientBuilder, sessionId)
}
}
@@ -35,7 +35,6 @@ import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientBuilder import org.matrix.rustcomponents.sdk.ClientBuilder
import org.matrix.rustcomponents.sdk.CrossProcessLockConfig import org.matrix.rustcomponents.sdk.CrossProcessLockConfig
import org.matrix.rustcomponents.sdk.RequestConfig import org.matrix.rustcomponents.sdk.RequestConfig
@@ -100,7 +99,12 @@ class RustMatrixClientFactory(
) )
.homeserverUrl(sessionData.homeserverUrl) .homeserverUrl(sessionData.homeserverUrl)
.enableAutomaticBackPagination(featureFlagService.isFeatureEnabled(FeatureFlags.AutomaticBackPagination)) .enableAutomaticBackPagination(featureFlagService.isFeatureEnabled(FeatureFlags.AutomaticBackPagination))
.let { (clientBuilderEnterpriseHook(RustMatrixClientBuilder(it), SessionId(sessionData.userId)) as RustMatrixClientBuilder).inner } .let {
(clientBuilderEnterpriseHook.beforeClientCreationWithSession(
clientBuilder = RustMatrixClientBuilder(it),
sessionId = SessionId(sessionData.userId),
) as RustMatrixClientBuilder).inner
}
.use { it.build() } .use { it.build() }
client.setMediaRetentionPolicy( client.setMediaRetentionPolicy(
@@ -118,14 +122,6 @@ class RustMatrixClientFactory(
client.restoreSession(sessionData.toSession()) client.restoreSession(sessionData.toSession())
create(client, sessionData, isMessageSearchAvailable)
}
suspend fun create(
client: Client,
sessionData: SessionData,
isMessageSearchAvailable: Boolean,
): RustMatrixClient {
val (anonymizedAccessToken, anonymizedRefreshToken) = client.session().anonymizedTokens() val (anonymizedAccessToken, anonymizedRefreshToken) = client.session().anonymizedTokens()
client.setUtdDelegate(UtdTracker(analyticsService)) client.setUtdDelegate(UtdTracker(analyticsService))
@@ -135,7 +131,7 @@ class RustMatrixClientFactory(
.withOfflineMode() .withOfflineMode()
.finish() .finish()
return RustMatrixClient( RustMatrixClient(
sessionPaths = sessionData.getSessionPaths(), sessionPaths = sessionData.getSessionPaths(),
innerClient = client, innerClient = client,
sessionStore = sessionStore, sessionStore = sessionStore,
@@ -227,6 +223,9 @@ class RustMatrixClientFactory(
// Workaround for non-nullable proxy parameter in the SDK, since each call to the ClientBuilder returns a new reference we need to keep // 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 proxyProvider.provides()?.let { proxy(it) } ?: this
} }
.let {
(clientBuilderEnterpriseHook.beforeClientCreation(RustMatrixClientBuilder(it)) as RustMatrixClientBuilder).inner
}
} }
} }
@@ -44,6 +44,7 @@ import io.element.android.libraries.matrix.impl.keys.SecretGenerator
import io.element.android.libraries.matrix.impl.mapper.toSessionData import io.element.android.libraries.matrix.impl.mapper.toSessionData
import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory
import io.element.android.libraries.sessionstorage.api.LoginType import io.element.android.libraries.sessionstorage.api.LoginType
import io.element.android.libraries.sessionstorage.api.SessionData
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@@ -101,17 +102,7 @@ class RustMatrixAuthenticationService(
runCatchingExceptions { runCatchingExceptions {
val sessionData = sessionStore.getSession(sessionId.value) val sessionData = sessionStore.getSession(sessionId.value)
if (sessionData != null) { if (sessionData != null) {
if (sessionData.isTokenValid) { restoreSession(sessionData)
// 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 {
throw SessionRestorationException.InvalidToken()
}
} else { } else {
throw SessionRestorationException.MissingSession(sessionId) throw SessionRestorationException.MissingSession(sessionId)
} }
@@ -120,6 +111,19 @@ class RustMatrixAuthenticationService(
} }
} }
private suspend fun restoreSession(sessionData: SessionData): MatrixClient {
if (!sessionData.isTokenValid) {
throw SessionRestorationException.InvalidToken()
}
// 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")
}
return rustMatrixClientFactory.create(sessionData)
}
private fun getDatabaseKey(): ClientSecret { private fun getDatabaseKey(): ClientSecret {
Timber.d("New sessions will be encrypted with a raw key") Timber.d("New sessions will be encrypted with a raw key")
return secretGenerator.generateKey() return secretGenerator.generateKey()
@@ -137,7 +141,7 @@ class RustMatrixAuthenticationService(
client.homeserverLoginDetails().map() client.homeserverLoginDetails().map()
}.onFailure { }.onFailure {
clear(destroyClient = true) clear()
}.mapFailure { failure -> }.mapFailure { failure ->
Timber.e(failure, "Failed to set homeserver to $homeserver") Timber.e(failure, "Failed to set homeserver to $homeserver")
failure.mapAuthenticationException() failure.mapAuthenticationException()
@@ -165,18 +169,7 @@ class RustMatrixAuthenticationService(
passphrase = pendingKey.formattedAsString(), passphrase = pendingKey.formattedAsString(),
sessionPaths = currentSessionPaths, sessionPaths = currentSessionPaths,
) )
val matrixClient = rustMatrixClientFactory.create(client, sessionData, isMessageSearchAvailable()) finalizeClientCreation(sessionData)
// Apply enterprise hooks to the newly created client as soon as possible
clientEnterpriseHook(matrixClient)
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
sessionStore.addSession(sessionData)
// Clean up the strong reference held here since it's no longer necessary
clear(destroyClient = false)
SessionId(sessionData.userId)
}.mapFailure { failure -> }.mapFailure { failure ->
Timber.e(failure, "Failed to login") Timber.e(failure, "Failed to login")
failure.mapAuthenticationException() failure.mapAuthenticationException()
@@ -305,20 +298,7 @@ class RustMatrixAuthenticationService(
passphrase = pendingKey.formattedAsString(), passphrase = pendingKey.formattedAsString(),
sessionPaths = currentSessionPaths, sessionPaths = currentSessionPaths,
) )
val matrixClient = rustMatrixClientFactory.create(client, sessionData, isMessageSearchAvailable()) finalizeClientCreation(sessionData)
// Apply enterprise hooks to the newly created client as soon as possible
clientEnterpriseHook(matrixClient)
matrixClient.waitForKnownVerificationState()
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
sessionStore.addSession(sessionData)
// Clean up the strong reference held here since it's no longer necessary
clear(destroyClient = false)
SessionId(sessionData.userId)
}.mapFailure { failure -> }.mapFailure { failure ->
Timber.e(failure, "Failed to login with OAuth") Timber.e(failure, "Failed to login with OAuth")
failure.mapAuthenticationException() failure.mapAuthenticationException()
@@ -357,6 +337,7 @@ class RustMatrixAuthenticationService(
sessionPaths = emptySessionPaths, sessionPaths = emptySessionPaths,
qrCodeData = sdkQrCodeLoginData, qrCodeData = sdkQrCodeLoginData,
) )
currentClient = client
client.newLoginWithQrCodeHandler( client.newLoginWithQrCodeHandler(
oauthConfiguration = oAuthConfiguration, oauthConfiguration = oAuthConfiguration,
).use { ).use {
@@ -374,18 +355,7 @@ class RustMatrixAuthenticationService(
passphrase = pendingKey.formattedAsString(), passphrase = pendingKey.formattedAsString(),
sessionPaths = emptySessionPaths, sessionPaths = emptySessionPaths,
) )
val matrixClient = rustMatrixClientFactory.create(client, sessionData, isMessageSearchAvailable()) finalizeClientCreation(sessionData)
// Apply enterprise hooks to the newly created client as soon as possible
clientEnterpriseHook(matrixClient)
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
sessionStore.addSession(sessionData)
// Clean up the strong reference held here since it's no longer necessary
clear(destroyClient = false)
SessionId(sessionData.userId)
}.mapFailure { }.mapFailure {
when (it) { when (it) {
is QrCodeDecodeException -> QrErrorMapper.map(it) is QrCodeDecodeException -> QrErrorMapper.map(it)
@@ -393,6 +363,8 @@ class RustMatrixAuthenticationService(
else -> it else -> it
} }
}.onFailure { throwable -> }.onFailure { throwable ->
// A QR code login always builds its own client, so it can be disposed of on failure.
clear()
if (throwable is CancellationException) { if (throwable is CancellationException) {
throw throwable throw throwable
} }
@@ -400,6 +372,27 @@ class RustMatrixAuthenticationService(
} }
} }
private suspend fun finalizeClientCreation(sessionData: SessionData): SessionId {
// Close the client which was used to perform the login before creating the final client.
// Both use the same session paths, so their SQLite stores must never be opened at the same time.
clear()
val matrixClient = restoreSession(sessionData)
// Apply enterprise hooks to the newly created client as soon as possible
clientEnterpriseHook(matrixClient)
matrixClient.waitForKnownVerificationState()
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
sessionStore.addSession(sessionData)
// The session paths now hold the data of the account which has just been logged in, so forget
// them: they must not be deleted by the rotateSessionPath() of the next login attempt.
sessionPaths = null
return SessionId(sessionData.userId)
}
private suspend fun makeClient( private suspend fun makeClient(
sessionPaths: SessionPaths, sessionPaths: SessionPaths,
config: suspend ClientBuilder.() -> ClientBuilder, config: suspend ClientBuilder.() -> ClientBuilder,
@@ -449,10 +442,8 @@ class RustMatrixAuthenticationService(
.build() .build()
} }
private fun clear(destroyClient: Boolean) { private fun clear() {
if (destroyClient) { currentClient?.close()
currentClient?.close()
}
currentClient = null currentClient = null
} }
@@ -9,11 +9,13 @@
package io.element.android.libraries.matrix.impl package io.element.android.libraries.matrix.impl
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.FakeClientBuilderEnterpriseHook
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider
import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory
import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider
import io.element.android.libraries.matrix.impl.storage.SqliteStoreBuilderProvider
import io.element.android.libraries.network.useragent.SimpleUserAgentProvider import io.element.android.libraries.network.useragent.SimpleUserAgentProvider
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
@@ -51,6 +53,7 @@ fun TestScope.createRustMatrixClientFactory(
), ),
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
sqliteStoreBuilderProvider: SqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(),
) = RustMatrixClientFactory( ) = RustMatrixClientFactory(
cacheDirectory = cacheDirectory, cacheDirectory = cacheDirectory,
appCoroutineScope = backgroundScope, appCoroutineScope = backgroundScope,
@@ -63,7 +66,7 @@ fun TestScope.createRustMatrixClientFactory(
featureFlagService = FakeFeatureFlagService(), featureFlagService = FakeFeatureFlagService(),
timelineEventFilterFactory = FakeTimelineEventFilterFactory(), timelineEventFilterFactory = FakeTimelineEventFilterFactory(),
clientBuilderProvider = clientBuilderProvider, clientBuilderProvider = clientBuilderProvider,
sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(), sqliteStoreBuilderProvider = sqliteStoreBuilderProvider,
workManagerScheduler = workManagerScheduler, workManagerScheduler = workManagerScheduler,
clientBuilderEnterpriseHook = { builder, _ -> builder }, clientBuilderEnterpriseHook = FakeClientBuilderEnterpriseHook(),
) )
@@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.impl package io.element.android.libraries.matrix.impl
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.FakeClientBuilderEnterpriseHook
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider
import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory
@@ -77,6 +78,6 @@ class RustTemporaryMatrixClientFactoryTest {
clientBuilderProvider = clientBuilderProvider, clientBuilderProvider = clientBuilderProvider,
sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(), sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(),
workManagerScheduler = workManagerScheduler, workManagerScheduler = workManagerScheduler,
clientBuilderEnterpriseHook = { builder, _ -> builder }, clientBuilderEnterpriseHook = FakeClientBuilderEnterpriseHook(),
) )
} }
@@ -12,25 +12,44 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.paths.SessionPaths
import io.element.android.libraries.matrix.impl.ClientBuilderProvider import io.element.android.libraries.matrix.impl.ClientBuilderProvider
import io.element.android.libraries.matrix.impl.FakeClientBuilderProvider import io.element.android.libraries.matrix.impl.FakeClientBuilderProvider
import io.element.android.libraries.matrix.impl.auth.qrlogin.SdkQrCodeLoginData
import io.element.android.libraries.matrix.impl.createRustMatrixClientFactory import io.element.android.libraries.matrix.impl.createRustMatrixClientFactory
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClientBuilder import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClientBuilder
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiHomeserverLoginDetails import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiHomeserverLoginDetails
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiLoginWithQrCodeHandler
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiQrCodeData
import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory
import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilder
import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider
import io.element.android.libraries.matrix.impl.storage.SqliteStoreBuilder
import io.element.android.libraries.matrix.impl.storage.SqliteStoreBuilderProvider
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.auth.FakeOAuthRedirectUrlProvider import io.element.android.libraries.matrix.test.auth.FakeOAuthRedirectUrlProvider
import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientBuilder
import org.matrix.rustcomponents.sdk.HumanQrLoginException
import java.io.File import java.io.File
class RustMatrixAuthenticationServiceTest { class RustMatrixAuthenticationServiceTest {
@get:Rule
val temporaryFolder = TemporaryFolder()
@Test @Test
fun `setHomeserver is successful`() = runTest { fun `setHomeserver is successful`() = runTest {
val sut = createRustMatrixAuthenticationService( val sut = createRustMatrixAuthenticationService(
@@ -74,20 +93,183 @@ class RustMatrixAuthenticationServiceTest {
closeResult.assertions().isCalledOnce() closeResult.assertions().isCalledOnce()
} }
@Test
fun `login closes the client used to log in before building the client of the session`() = runTest {
val events = mutableListOf<String>()
val sut = createRustMatrixAuthenticationService(
clientBuilderProvider = FakeSequentialClientBuilderProvider(
{
events.add("build login client")
FakeFfiClient(
homeserverLoginDetailsResult = { FakeFfiHomeserverLoginDetails() },
loginResult = { _, _ -> },
closeResult = { events.add("close login client") },
)
},
{
events.add("build session client")
FakeFfiClient(withUtdHook = {})
},
),
)
assertThat(sut.setHomeserver("matrix.org").isSuccess).isTrue()
assertThat(sut.login("alice", "password").getOrNull()).isEqualTo(A_USER_ID)
// The two clients share the same session paths, so the login one must be closed first.
assertThat(events).containsExactly("build login client", "close login client", "build session client").inOrder()
}
@Test
fun `loginWithQrCode closes the client used to log in before building the client of the session`() = runTest {
val events = mutableListOf<String>()
val sut = createRustMatrixAuthenticationService(
clientBuilderProvider = FakeSequentialClientBuilderProvider(
{
events.add("build login client")
FakeFfiClient(
newLoginWithQrCodeHandlerResult = { FakeFfiLoginWithQrCodeHandler() },
closeResult = { events.add("close login client") },
)
},
{
events.add("build session client")
FakeFfiClient(withUtdHook = {})
},
),
)
val result = sut.loginWithQrCode(aSdkQrCodeLoginData()) {}
assertThat(result.getOrNull()).isEqualTo(A_USER_ID)
assertThat(events).containsExactly("build login client", "close login client", "build session client").inOrder()
}
@Test
fun `loginWithQrCode closes the client it created when the login fails`() = runTest {
val closeResult = lambdaRecorder<Unit> {}
val sut = createRustMatrixAuthenticationService(
clientBuilderProvider = FakeClientBuilderProvider(
provideResult = {
FakeFfiClientBuilder(
buildResult = {
FakeFfiClient(
newLoginWithQrCodeHandlerResult = {
FakeFfiLoginWithQrCodeHandler(
scanResult = { throw HumanQrLoginException.Unknown() },
)
},
closeResult = closeResult,
)
},
)
},
),
)
assertThat(sut.loginWithQrCode(aSdkQrCodeLoginData()) {}.isFailure).isTrue()
closeResult.assertions().isCalledOnce()
}
private fun aSdkQrCodeLoginData() = SdkQrCodeLoginData(
FakeFfiQrCodeData(
baseUrlResult = { A_HOMESERVER_URL },
)
)
/**
* A [ClientBuilderProvider] handing out one [Client] per call, in order.
*/
private class FakeSequentialClientBuilderProvider(
private vararg val clients: () -> Client,
) : ClientBuilderProvider {
private var index = 0
override fun provide(): ClientBuilder = FakeFfiClientBuilder(buildResult = clients[index++])
}
@Test
fun `a new login does not delete the session data of the account which has just been logged in`() = runTest {
val storeBuilderProvider = SessionDirectoryCreatingSqliteStoreBuilderProvider()
val sut = createRustMatrixAuthenticationService(
clientBuilderProvider = FakeSequentialClientBuilderProvider(
{ aLoginFakeFfiClient() },
{ FakeFfiClient(withUtdHook = {}) },
{ aLoginFakeFfiClient() },
),
sessionPathsFactory = SessionPathsFactory(temporaryFolder.newFolder("base"), temporaryFolder.newFolder("cache")),
sqliteStoreBuilderProvider = storeBuilderProvider,
)
assertThat(sut.setHomeserver("matrix.org").isSuccess).isTrue()
assertThat(sut.login("alice", "password").getOrNull()).isEqualTo(A_USER_ID)
val loggedInSessionPaths = storeBuilderProvider.providedSessionPaths.first()
// Adding another account rotates the session paths, which must not touch the previous account.
assertThat(sut.setHomeserver("matrix.org").isSuccess).isTrue()
assertThat(loggedInSessionPaths.fileDirectory.exists()).isTrue()
assertThat(loggedInSessionPaths.cacheDirectory.exists()).isTrue()
}
@Test
fun `a new login deletes the session data of a previous failed login attempt`() = runTest {
val storeBuilderProvider = SessionDirectoryCreatingSqliteStoreBuilderProvider()
val sut = createRustMatrixAuthenticationService(
clientBuilderProvider = FakeSequentialClientBuilderProvider(
{ aLoginFakeFfiClient() },
{ throw IllegalStateException("Failed to build the client of the session") },
{ aLoginFakeFfiClient() },
),
sessionPathsFactory = SessionPathsFactory(temporaryFolder.newFolder("base"), temporaryFolder.newFolder("cache")),
sqliteStoreBuilderProvider = storeBuilderProvider,
)
assertThat(sut.setHomeserver("matrix.org").isSuccess).isTrue()
assertThat(sut.login("alice", "password").isFailure).isTrue()
val abandonedSessionPaths = storeBuilderProvider.providedSessionPaths.first()
assertThat(sut.setHomeserver("matrix.org").isSuccess).isTrue()
assertThat(abandonedSessionPaths.fileDirectory.exists()).isFalse()
assertThat(abandonedSessionPaths.cacheDirectory.exists()).isFalse()
}
private fun aLoginFakeFfiClient() = FakeFfiClient(
homeserverLoginDetailsResult = { FakeFfiHomeserverLoginDetails() },
loginResult = { _, _ -> },
)
/**
* A [SqliteStoreBuilderProvider] which creates the session directories, like the SDK does when it
* opens its stores, and records them so that a test can assert on their lifecycle.
*/
private class SessionDirectoryCreatingSqliteStoreBuilderProvider : SqliteStoreBuilderProvider {
val providedSessionPaths = mutableListOf<SessionPaths>()
override fun provide(sessionPaths: SessionPaths): SqliteStoreBuilder {
sessionPaths.fileDirectory.mkdirs()
sessionPaths.cacheDirectory.mkdirs()
providedSessionPaths.add(sessionPaths)
return FakeSqliteStoreBuilder()
}
}
private fun TestScope.createRustMatrixAuthenticationService( private fun TestScope.createRustMatrixAuthenticationService(
sessionStore: SessionStore = InMemorySessionStore(), sessionStore: SessionStore = InMemorySessionStore(updateUserProfileResult = { _, _, _ -> }),
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
enterpriseService: EnterpriseService = FakeEnterpriseService(), enterpriseService: EnterpriseService = FakeEnterpriseService(),
sessionPathsFactory: SessionPathsFactory = SessionPathsFactory(File("/base"), File("/cache")),
sqliteStoreBuilderProvider: SqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(),
): RustMatrixAuthenticationService { ): RustMatrixAuthenticationService {
val baseDirectory = File("/base")
val cacheDirectory = File("/cache")
val rustMatrixClientFactory = createRustMatrixClientFactory( val rustMatrixClientFactory = createRustMatrixClientFactory(
cacheDirectory = cacheDirectory, cacheDirectory = File("/cache"),
sessionStore = sessionStore, sessionStore = sessionStore,
clientBuilderProvider = clientBuilderProvider, clientBuilderProvider = clientBuilderProvider,
workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}),
sqliteStoreBuilderProvider = sqliteStoreBuilderProvider,
) )
return RustMatrixAuthenticationService( return RustMatrixAuthenticationService(
sessionPathsFactory = SessionPathsFactory(baseDirectory, cacheDirectory), sessionPathsFactory = sessionPathsFactory,
coroutineDispatchers = testCoroutineDispatchers(), coroutineDispatchers = testCoroutineDispatchers(),
sessionStore = sessionStore, sessionStore = sessionStore,
rustMatrixClientFactory = rustMatrixClientFactory, rustMatrixClientFactory = rustMatrixClientFactory,
@@ -25,10 +25,12 @@ import org.matrix.rustcomponents.sdk.Encryption
import org.matrix.rustcomponents.sdk.HomeserverCapabilities import org.matrix.rustcomponents.sdk.HomeserverCapabilities
import org.matrix.rustcomponents.sdk.HomeserverLoginDetails import org.matrix.rustcomponents.sdk.HomeserverLoginDetails
import org.matrix.rustcomponents.sdk.IgnoredUsersListener import org.matrix.rustcomponents.sdk.IgnoredUsersListener
import org.matrix.rustcomponents.sdk.LoginWithQrCodeHandler
import org.matrix.rustcomponents.sdk.NoHandle import org.matrix.rustcomponents.sdk.NoHandle
import org.matrix.rustcomponents.sdk.NotificationClient import org.matrix.rustcomponents.sdk.NotificationClient
import org.matrix.rustcomponents.sdk.NotificationProcessSetup import org.matrix.rustcomponents.sdk.NotificationProcessSetup
import org.matrix.rustcomponents.sdk.NotificationSettings import org.matrix.rustcomponents.sdk.NotificationSettings
import org.matrix.rustcomponents.sdk.OAuthConfiguration
import org.matrix.rustcomponents.sdk.ProfileListener import org.matrix.rustcomponents.sdk.ProfileListener
import org.matrix.rustcomponents.sdk.PusherIdentifiers import org.matrix.rustcomponents.sdk.PusherIdentifiers
import org.matrix.rustcomponents.sdk.PusherKind import org.matrix.rustcomponents.sdk.PusherKind
@@ -57,6 +59,8 @@ class FakeFfiClient(
private val withUtdHook: (UnableToDecryptDelegate) -> Unit = { lambdaError() }, private val withUtdHook: (UnableToDecryptDelegate) -> Unit = { lambdaError() },
private val getProfileResult: (String) -> UserProfile = { aRustUserProfile() }, private val getProfileResult: (String) -> UserProfile = { aRustUserProfile() },
private val homeserverLoginDetailsResult: () -> HomeserverLoginDetails = { lambdaError() }, private val homeserverLoginDetailsResult: () -> HomeserverLoginDetails = { lambdaError() },
private val loginResult: (String, String) -> Unit = { _, _ -> lambdaError() },
private val newLoginWithQrCodeHandlerResult: () -> LoginWithQrCodeHandler = { lambdaError() },
private val getStoreSizesResult: () -> StoreSizes = { lambdaError() }, private val getStoreSizesResult: () -> StoreSizes = { lambdaError() },
private val createRoomResult: (CreateRoomParameters) -> String = { lambdaError() }, private val createRoomResult: (CreateRoomParameters) -> String = { lambdaError() },
private val homeserverCapabilities: HomeserverCapabilities = FakeFfiHomeserverCapabilities(), private val homeserverCapabilities: HomeserverCapabilities = FakeFfiHomeserverCapabilities(),
@@ -122,6 +126,14 @@ class FakeFfiClient(
return homeserverLoginDetailsResult() return homeserverLoginDetailsResult()
} }
override suspend fun login(username: String, password: String, initialDeviceName: String?, deviceId: String?) {
loginResult(username, password)
}
override fun newLoginWithQrCodeHandler(oauthConfiguration: OAuthConfiguration): LoginWithQrCodeHandler {
return newLoginWithQrCodeHandlerResult()
}
override suspend fun setMediaRetentionPolicy(policy: MediaRetentionPolicy) {} override suspend fun setMediaRetentionPolicy(policy: MediaRetentionPolicy) {}
override suspend fun getStoreSizes(): StoreSizes { override suspend fun getStoreSizes(): StoreSizes {
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.fixtures.fakes
import org.matrix.rustcomponents.sdk.GeneratedQrLoginProgressListener
import org.matrix.rustcomponents.sdk.LoginWithQrCodeHandler
import org.matrix.rustcomponents.sdk.NoHandle
import org.matrix.rustcomponents.sdk.QrCodeData
import org.matrix.rustcomponents.sdk.QrLoginProgress
import org.matrix.rustcomponents.sdk.QrLoginProgressListener
class FakeFfiLoginWithQrCodeHandler(
private val generateResult: suspend () -> Unit = {},
private val scanResult: suspend (QrCodeData) -> Unit = {},
) : LoginWithQrCodeHandler(NoHandle) {
private var scanProgressListener: QrLoginProgressListener? = null
override suspend fun generate(progressListener: GeneratedQrLoginProgressListener) {
generateResult()
}
override suspend fun scan(qrCodeData: QrCodeData, progressListener: QrLoginProgressListener) {
scanProgressListener = progressListener
scanResult(qrCodeData)
}
fun emitScanProgress(progress: QrLoginProgress) {
scanProgressListener?.onUpdate(progress)
}
override fun close() = Unit
}
@@ -14,12 +14,17 @@ import org.matrix.rustcomponents.sdk.QrCodeData
class FakeFfiQrCodeData( class FakeFfiQrCodeData(
private val serverNameResult: () -> String? = { lambdaError() }, private val serverNameResult: () -> String? = { lambdaError() },
private val baseUrlResult: () -> String? = { lambdaError() },
private val toBytesResult: () -> ByteArray = { lambdaError() }, private val toBytesResult: () -> ByteArray = { lambdaError() },
) : QrCodeData(NoHandle) { ) : QrCodeData(NoHandle) {
override fun serverName(): String? { override fun serverName(): String? {
return serverNameResult() return serverNameResult()
} }
override fun baseUrl(): String? {
return baseUrlResult()
}
override fun toBytes(): ByteArray { override fun toBytes(): ByteArray {
return toBytesResult() return toBytesResult()
} }
@@ -105,6 +105,7 @@ class KonsistClassNameTest {
"FakeListenableFuture", "FakeListenableFuture",
"FakeMediaStoreContentProvider", "FakeMediaStoreContentProvider",
"FakeParentNode", "FakeParentNode",
"FakeSequentialClientBuilderProvider",
) )
.assertTrue { .assertTrue {
val interfaceName = it.name val interfaceName = it.name