Sign in with Classic

This commit is contained in:
Benoit Marty 2026-03-04 16:11:37 +01:00 committed by Benoit Marty
parent 683b1fe9d5
commit 8c5caabed4
62 changed files with 3120 additions and 728 deletions

View file

@ -0,0 +1,33 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.androidutils.service
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.annotations.ApplicationContext
interface ServiceBinder {
fun bindService(service: Intent, conn: ServiceConnection, flags: Int): Boolean
fun unbindService(conn: ServiceConnection)
}
@ContributesBinding(AppScope::class)
class DefaultServiceBinder(
@ApplicationContext private val context: Context,
) : ServiceBinder {
override fun bindService(service: Intent, conn: ServiceConnection, flags: Int): Boolean {
return context.bindService(service, conn, flags)
}
override fun unbindService(conn: ServiceConnection) {
context.unbindService(conn)
}
}

View file

@ -0,0 +1,54 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.architecture.appyx
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.spring
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.navigation.transition.ModifierTransitionHandler
import com.bumble.appyx.core.navigation.transition.TransitionDescriptor
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.NewRoot
import com.bumble.appyx.navmodel.backstack.operation.Replace
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackFader
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackSlider
/**
* A TransitionHandler that uses fade transition when the operation is Replace or NewRoot,
* and slide transition for all other cases.
*/
private class FaderOrSliderTransitionHandler<NavTarget>(
private val slider: ModifierTransitionHandler<NavTarget, BackStack.State>,
private val fader: ModifierTransitionHandler<NavTarget, BackStack.State>,
) : ModifierTransitionHandler<NavTarget, BackStack.State>() {
override fun createModifier(
modifier: Modifier,
transition: Transition<BackStack.State>,
descriptor: TransitionDescriptor<NavTarget, BackStack.State>
): Modifier {
val operation = descriptor.operation
val useFader = operation is Replace || operation is NewRoot
val handler = if (useFader) fader else slider
return handler.createModifier(modifier, transition, descriptor)
}
}
@Composable
fun <NavTarget> rememberFaderOrSliderTransitionHandler(): ModifierTransitionHandler<NavTarget, BackStack.State> {
val slider = rememberBackstackSlider<NavTarget>(
transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) },
)
val fader = rememberBackstackFader<NavTarget>(
transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) },
)
return rememberDelegateTransitionHandler {
FaderOrSliderTransitionHandler(slider, fader)
}
}

View file

@ -0,0 +1,79 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.components.avatar
import android.graphics.Bitmap
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import io.element.android.libraries.designsystem.components.avatar.internal.InitialLetterAvatar
import timber.log.Timber
// For user avatar only.
@Composable
fun BitmapAvatar(
avatarData: AvatarData,
bitmap: Bitmap?,
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
val avatarShape = AvatarType.User.avatarShape()
when {
bitmap == null -> InitialLetterAvatar(
avatarData = avatarData,
avatarShape = avatarShape,
forcedAvatarSize = null,
modifier = modifier,
contentDescription = contentDescription,
)
else -> {
val size = avatarData.size.dp
SubcomposeAsyncImage(
model = bitmap,
contentDescription = contentDescription,
contentScale = ContentScale.Crop,
modifier = modifier
.size(size)
.clip(avatarShape)
) {
val collectedState by painter.state.collectAsState()
when (val state = collectedState) {
is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent()
is AsyncImagePainter.State.Error -> {
SideEffect {
Timber.e(
state.result.throwable,
"Error loading avatar $state\n${state.result}"
)
}
InitialLetterAvatar(
avatarData = avatarData,
avatarShape = avatarShape,
forcedAvatarSize = null,
contentDescription = contentDescription,
)
}
else -> InitialLetterAvatar(
avatarData = avatarData,
avatarShape = avatarShape,
forcedAvatarSize = null,
contentDescription = contentDescription,
)
}
}
}
}
}

View file

@ -0,0 +1,18 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.auth
import io.element.android.libraries.matrix.api.core.UserId
data class ElementClassicSession(
val userId: UserId,
val homeserverUrl: String?,
val secrets: String?,
val roomKeysVersion: String?,
val doesContainBackupKey: Boolean,
)

View file

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

View file

@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.core
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class UserIdTest {
@Test
fun `valid user id`() {
val userId = UserId("@alice:example.org")
assertThat(userId.extractedDisplayName).isEqualTo("alice")
assertThat(userId.domainName).isEqualTo("example.org")
}
}

View file

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

View file

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