Merge branch 'develop' into feature/bma/leaveSpace

This commit is contained in:
Benoit Marty 2025-09-26 15:46:57 +02:00 committed by GitHub
commit 0e3efafa6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
117 changed files with 2158 additions and 287 deletions

View file

@ -0,0 +1,18 @@
/*
* 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.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.accountselect.api"
}
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
}

View file

@ -0,0 +1,28 @@
/*
* 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.accountselect.api
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.SessionId
interface AccountSelectEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder {
fun callback(callback: Callback): NodeBuilder
fun build(): Node
}
interface Callback : Plugin {
fun onSelectAccount(sessionId: SessionId)
fun onCancel()
}
}

View file

@ -0,0 +1,35 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* 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.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.libraries.accountselect.impl"
}
setupDependencyInjection()
dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
api(projects.libraries.accountselect.api)
testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.sessionStorage.test)
}

View file

@ -0,0 +1,49 @@
/*
* 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.accountselect.impl
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint
import io.element.android.libraries.matrix.api.core.SessionId
@ContributesNode(AppScope::class)
@AssistedInject
class AccountSelectNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: AccountSelectPresenter,
) : Node(buildContext, plugins = plugins) {
private val callbacks = plugins.filterIsInstance<AccountSelectEntryPoint.Callback>()
private fun onDismiss() {
callbacks.forEach { it.onCancel() }
}
private fun onSelectAccount(sessionId: SessionId) {
callbacks.forEach { it.onSelectAccount(sessionId) }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
AccountSelectView(
state = state,
onDismiss = ::onDismiss,
onSelectAccount = ::onSelectAccount,
modifier = modifier,
)
}
}

View file

@ -0,0 +1,44 @@
/*
* 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.accountselect.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
@Inject
class AccountSelectPresenter(
private val sessionStore: SessionStore,
) : Presenter<AccountSelectState> {
@Composable
override fun present(): AccountSelectState {
val accounts by produceState(persistentListOf()) {
// Do not use sessionStore.sessionsFlow() to not make it change when an account is selected.
value = sessionStore.getAllSessions()
.map {
MatrixUser(
userId = UserId(it.userId),
displayName = it.userDisplayName,
avatarUrl = it.userAvatarUrl,
)
}
.toPersistentList()
}
return AccountSelectState(
accounts = accounts,
)
}
}

View file

@ -0,0 +1,15 @@
/*
* 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.accountselect.impl
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
data class AccountSelectState(
val accounts: ImmutableList<MatrixUser>,
)

View file

@ -0,0 +1,27 @@
/*
* 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.accountselect.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import kotlinx.collections.immutable.toPersistentList
open class AccountSelectStateProvider : PreviewParameterProvider<AccountSelectState> {
override val values: Sequence<AccountSelectState>
get() = sequenceOf(
anAccountSelectState(),
anAccountSelectState(accounts = aMatrixUserList()),
)
}
private fun anAccountSelectState(
accounts: List<MatrixUser> = listOf(),
) = AccountSelectState(
accounts = accounts.toPersistentList(),
)

View file

@ -0,0 +1,88 @@
/*
* 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.accountselect.impl
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.ui.strings.CommonStrings
@Suppress("MultipleEmitters") // False positive
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountSelectView(
state: AccountSelectState,
onSelectAccount: (SessionId) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
BackHandler(onBack = { onDismiss() })
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
titleStr = stringResource(CommonStrings.common_select_account),
navigationIcon = {
BackButton(onClick = { onDismiss() })
},
)
}
) { paddingValues ->
Column(
Modifier
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
) {
LazyColumn {
items(state.accounts, key = { it.userId }) { matrixUser ->
Column {
MatrixUserRow(
modifier = Modifier
.fillMaxWidth()
.clickable {
onSelectAccount(matrixUser.userId)
}
.padding(vertical = 8.dp),
matrixUser = matrixUser,
)
HorizontalDivider()
}
}
}
}
}
}
@PreviewsDayNight
@Composable
internal fun AccountSelectViewPreview(@PreviewParameter(AccountSelectStateProvider::class) state: AccountSelectState) = ElementPreview {
AccountSelectView(
state = state,
onSelectAccount = {},
onDismiss = {},
)
}

View file

@ -0,0 +1,36 @@
/*
* 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.accountselect.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint
import io.element.android.libraries.architecture.createNode
@ContributesBinding(AppScope::class)
@Inject
class DefaultAccountSelectEntryPoint : AccountSelectEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): AccountSelectEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : AccountSelectEntryPoint.NodeBuilder {
override fun callback(callback: AccountSelectEntryPoint.Callback): AccountSelectEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node {
return parentNode.createNode<AccountSelectNode>(buildContext, plugins)
}
}
}
}

View file

@ -0,0 +1,78 @@
/*
* 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.accountselect.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class AccountSelectPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = createAccountSelectPresenter()
presenter.test {
val initialState = awaitItem()
assertThat(initialState.accounts).isEmpty()
}
}
@Test
fun `present - multiple accounts case`() = runTest {
val presenter = createAccountSelectPresenter(
sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(sessionId = A_SESSION_ID.value),
aSessionData(
sessionId = A_SESSION_ID_2.value,
userDisplayName = "Bob",
userAvatarUrl = "avatarUrl",
),
)
)
)
presenter.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.accounts).hasSize(2)
val firstAccount = initialState.accounts[0]
assertThat(firstAccount).isEqualTo(
MatrixUser(
userId = A_SESSION_ID,
displayName = null,
avatarUrl = null,
)
)
val secondAccount = initialState.accounts[1]
assertThat(secondAccount).isEqualTo(
MatrixUser(
userId = A_SESSION_ID_2,
displayName = "Bob",
avatarUrl = "avatarUrl",
)
)
}
}
}
internal fun createAccountSelectPresenter(
sessionStore: SessionStore = InMemorySessionStore(),
) = AccountSelectPresenter(
sessionStore = sessionStore,
)

View file

@ -0,0 +1,44 @@
/*
* 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.accountselect.impl
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.accountselect.api.AccountSelectEntryPoint
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
class DefaultAccountSelectEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Test
fun `test node builder`() {
val entryPoint = DefaultAccountSelectEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
AccountSelectNode(
buildContext = buildContext,
plugins = plugins,
presenter = createAccountSelectPresenter(),
)
}
val callback = object : AccountSelectEntryPoint.Callback {
override fun onSelectAccount(sessionId: SessionId) = lambdaError()
override fun onCancel() = lambdaError()
}
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
.callback(callback)
.build()
assertThat(result).isInstanceOf(AccountSelectNode::class.java)
assertThat(result.plugins).contains(callback)
}
}

View file

@ -0,0 +1,35 @@
/*
* 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.architecture.appyx
import android.annotation.SuppressLint
import androidx.compose.animation.core.Transition
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.navigation.transition.ModifierTransitionHandler
import com.bumble.appyx.core.navigation.transition.TransitionDescriptor
/**
* A [ModifierTransitionHandler] that delegates the creation of the modifier to another handler
* based on the [NavTarget]. The idea is to allow different transitions for different [NavTarget]s.
*/
class DelegateTransitionHandler<NavTarget, State>(
private val handlerProvider: (NavTarget) -> ModifierTransitionHandler<NavTarget, State>,
) : ModifierTransitionHandler<NavTarget, State>() {
@SuppressLint("ModifierFactoryExtensionFunction")
override fun createModifier(modifier: Modifier, transition: Transition<State>, descriptor: TransitionDescriptor<NavTarget, State>): Modifier {
return handlerProvider(descriptor.element).createModifier(modifier, transition, descriptor)
}
}
@Composable
fun <NavTarget, State> rememberDelegateTransitionHandler(
handlerProvider: (NavTarget) -> ModifierTransitionHandler<NavTarget, State>,
): ModifierTransitionHandler<NavTarget, State> =
remember(handlerProvider) { DelegateTransitionHandler(handlerProvider = handlerProvider) }

View file

@ -73,4 +73,6 @@ enum class AvatarSize(val dp: Dp) {
RoomPreviewInviter(56.dp),
SpaceMember(24.dp),
LeaveSpaceRoom(32.dp),
AccountItem(32.dp),
}

View file

@ -99,5 +99,13 @@ enum class FeatureFlags(
description = "Renders thread messages as a dedicated timeline. Restarting the app is required for this setting to fully take effect.",
defaultValue = { false },
isFinished = false,
)
),
MultiAccount(
key = "feature.multi_account",
title = "Multi accounts",
description = "Allow the application to connect to multiple accounts at the same time." +
"\n\nWARNING: this feature is EXPERIMENTAL and UNSTABLE.",
defaultValue = { false },
isFinished = false,
),
}

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.api.auth
sealed class AuthenticationException(message: String) : Exception(message) {
class AccountAlreadyLoggedIn(userId: String) : AuthenticationException(userId)
class InvalidServerName(message: String) : AuthenticationException(message)
class SlidingSyncVersion(message: String) : AuthenticationException(message)
class Oidc(message: String) : AuthenticationException(message)

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.api.permalink
import android.net.Uri
import android.os.Parcelable
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
@ -15,13 +16,15 @@ import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.parcelize.Parcelize
/**
* This sealed class represents all the permalink cases.
* You don't have to instantiate yourself but should use [PermalinkParser] instead.
*/
@Immutable
sealed interface PermalinkData {
@Parcelize
sealed interface PermalinkData : Parcelable {
data class RoomLink(
val roomIdOrAlias: RoomIdOrAlias,
val eventId: EventId? = null,

View file

@ -235,7 +235,6 @@ class RustMatrixClient(
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(
MatrixUser(
userId = sessionId,
// TODO cache for displayName?
displayName = null,
avatarUrl = null,
)
@ -264,6 +263,16 @@ class RustMatrixClient(
// 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()
}
@ -399,7 +408,15 @@ class RustMatrixClient(
}
override suspend fun getUserProfile(): Result<MatrixUser> = getProfile(sessionId)
.onSuccess { _userProfile.tryEmit(it) }
.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) {

View file

@ -14,6 +14,7 @@ import org.matrix.rustcomponents.sdk.OidcException
fun Throwable.mapAuthenticationException(): AuthenticationException {
val message = this.message ?: "Unknown error"
return when (this) {
is AuthenticationException -> this
is ClientBuildException -> when (this) {
is ClientBuildException.Generic -> AuthenticationException.Generic(message)
is ClientBuildException.InvalidServerName -> AuthenticationException.InvalidServerName(message)

View file

@ -15,6 +15,7 @@ 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
@ -139,6 +140,8 @@ class RustMatrixAuthenticationService(
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,
@ -227,17 +230,19 @@ class RustMatrixAuthenticationService(
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,
)
// Free the pending data since we won't use it to abort the flow anymore
pendingOAuthAuthorizationData?.close()
pendingOAuthAuthorizationData = null
val matrixClient = rustMatrixClientFactory.create(client)
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
sessionStore.addSession(sessionData)
@ -253,6 +258,21 @@ class RustMatrixAuthenticationService(
}
}
@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
@ -275,7 +295,8 @@ class RustMatrixAuthenticationService(
oidcConfiguration = oidcConfiguration,
progressListener = progressListener,
)
// Ensure that the user is not already logged in with the same account
ensureNotAlreadyLoggedIn(client)
val sessionData = client.session()
.toSessionData(
isTokenValid = true,

View file

@ -34,6 +34,11 @@ internal fun Session.toSessionData(
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(
@ -55,4 +60,8 @@ internal fun ExternalSession.toSessionData(
passphrase = passphrase,
sessionPath = sessionPaths.fileDirectory.absolutePath,
cachePath = sessionPaths.cacheDirectory.absolutePath,
position = 0,
lastUsageIndex = 0,
userDisplayName = null,
userAvatarUrl = null,
)

View file

@ -38,7 +38,9 @@ class RustMatrixClientFactoryTest {
fun TestScope.createRustMatrixClientFactory(
baseDirectory: File = File("/base"),
cacheDirectory: File = File("/cache"),
sessionStore: SessionStore = InMemorySessionStore(),
sessionStore: SessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _ -> },
),
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
) = RustMatrixClientFactory(
baseDirectory = baseDirectory,

View file

@ -5,6 +5,8 @@
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.libraries.matrix.impl
import com.google.common.truth.Truth.assertThat
@ -12,17 +14,24 @@ import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiSyncService
import io.element.android.libraries.matrix.impl.room.FakeTimelineEventTypeFilterFactory
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_DEVICE_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.UserProfile
import java.io.File
class RustMatrixClientTest {
@ -51,9 +60,46 @@ class RustMatrixClientTest {
client.destroy()
}
@Test
fun `retrieving the UserProfile updates the database`() = runTest {
val updateUserProfileResult = lambdaRecorder<String, String?, String?, Unit> { _, _, _ -> }
val sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(
sessionId = A_USER_ID.value,
userDisplayName = null,
userAvatarUrl = null,
)
),
updateUserProfileResult = updateUserProfileResult,
)
val client = createRustMatrixClient(
client = FakeFfiClient(
getProfileResult = { userId ->
UserProfile(
userId = userId,
displayName = A_USER_NAME,
avatarUrl = AN_AVATAR_URL,
)
},
),
sessionStore = sessionStore,
)
advanceUntilIdle()
updateUserProfileResult.assertions().isCalledOnce()
.with(
value(A_USER_ID.value),
value(A_USER_NAME),
value(AN_AVATAR_URL),
)
client.destroy()
}
private fun TestScope.createRustMatrixClient(
client: Client = FakeFfiClient(),
sessionStore: SessionStore = InMemorySessionStore(),
sessionStore: SessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _ -> },
),
) = RustMatrixClient(
innerClient = client,
baseDirectory = File(""),

View file

@ -42,6 +42,7 @@ class FakeFfiClient(
private val session: Session = aRustSession(),
private val clearCachesResult: () -> Unit = { lambdaError() },
private val withUtdHook: (UnableToDecryptDelegate) -> Unit = { lambdaError() },
private val getProfileResult: (String) -> UserProfile = { UserProfile(userId = userId, displayName = null, avatarUrl = null) },
private val homeserverLoginDetailsResult: () -> HomeserverLoginDetails = { lambdaError() },
private val closeResult: () -> Unit = {},
) : Client(NoPointer) {
@ -79,7 +80,7 @@ class FakeFfiClient(
}
override suspend fun getProfile(userId: String): UserProfile {
return UserProfile(userId = userId, displayName = null, avatarUrl = null)
return getProfileResult(userId)
}
override suspend fun homeserverLoginDetails(): HomeserverLoginDetails {

View file

@ -42,6 +42,5 @@ class FakeFfiClientBuilder(
override fun username(username: String) = this
override fun enableShareHistoryOnInvite(enableShareHistoryOnInvite: Boolean): ClientBuilder = this
override fun threadsEnabled(enabled: Boolean, threadSubscriptions: Boolean): ClientBuilder = this
override suspend fun build() = buildResult()
}

View file

@ -8,6 +8,6 @@
package io.element.android.libraries.oidc.api
sealed interface OidcAction {
data object GoBack : OidcAction
data class GoBack(val toUnblock: Boolean = false) : OidcAction
data class Success(val url: String) : OidcAction
}

View file

@ -36,7 +36,7 @@ class DefaultOidcUrlParser(
*/
override fun parse(url: String): OidcAction? {
if (url.startsWith(oidcRedirectUrlProvider.provide()).not()) return null
if (url.contains("error=access_denied")) return OidcAction.GoBack
if (url.contains("error=access_denied")) return OidcAction.GoBack()
if (url.contains("code=")) return OidcAction.Success(url)
// Other case not supported, let's crash the app for now

View file

@ -24,10 +24,10 @@ class DefaultOidcActionFlowTest {
data.add(action)
}
}
sut.post(OidcAction.GoBack)
sut.post(OidcAction.GoBack())
delay(1)
sut.reset()
delay(1)
assertThat(data).containsExactly(OidcAction.GoBack, null)
assertThat(data).containsExactly(OidcAction.GoBack(), null)
}
}

View file

@ -29,7 +29,7 @@ class DefaultOidcIntentResolverTest {
data = "io.element.android:/?error=access_denied&state=IFF1UETGye2ZA8pO".toUri()
}
val result = sut.resolve(intent)
assertThat(result).isEqualTo(OidcAction.GoBack)
assertThat(result).isEqualTo(OidcAction.GoBack())
}
@Test

View file

@ -31,7 +31,7 @@ class DefaultOidcUrlParserTest {
fun `test cancel url`() {
val sut = createDefaultOidcUrlParser()
val aCancelUrl = "$FAKE_REDIRECT_URL?error=access_denied&state=IFF1UETGye2ZA8pO"
assertThat(sut.parse(aCancelUrl)).isEqualTo(OidcAction.GoBack)
assertThat(sut.parse(aCancelUrl)).isEqualTo(OidcAction.GoBack())
}
@Test

View file

@ -39,4 +39,12 @@ data class SessionData(
val sessionPath: String,
/** The path to the cache data stored for the session in the filesystem. */
val cachePath: String,
/** The position, to be able to order account. */
val position: Long,
/** The index of the last date of session usage. */
val lastUsageIndex: Long,
/** The optional display name of the user. */
val userDisplayName: String?,
/** The optional avatar URL of the user. */
val userAvatarUrl: String?,
)

View file

@ -11,8 +11,22 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
interface SessionStore {
/**
* A flow emitting the current logged in state.
* If there is at least one session, the state is [LoggedInState.LoggedIn] with the latest used session.
* If there is no session, the state is [LoggedInState.NotLoggedIn].
*/
fun loggedInStateFlow(): Flow<LoggedInState>
/**
* Return a flow of all sessions ordered by last usage descending.
*/
fun sessionsFlow(): Flow<List<SessionData>>
/**
* Add a new session. If other sessions exist, the new one will be set as the latest used one, and
* the added session position will be set to a value higher than the other session positions.
*/
suspend fun addSession(sessionData: SessionData)
/**
@ -20,9 +34,35 @@ interface SessionStore {
* No op if userId is not found in DB.
*/
suspend fun updateData(sessionData: SessionData)
/**
* Update the user profile info of the session matching the userId.
*/
suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?)
/**
* Get the session data matching the userId, or null if not found.
*/
suspend fun getSession(sessionId: String): SessionData?
/**
* Get all sessions ordered by last usage descending.
*/
suspend fun getAllSessions(): List<SessionData>
/**
* Get the latest session, or null if no session exists.
*/
suspend fun getLatestSession(): SessionData?
/**
* Set the session with [sessionId] as the latest used one.
*/
suspend fun setLatestSession(sessionId: String)
/**
* Remove the session matching the sessionId.
*/
suspend fun removeSession(sessionId: String)
}

View file

@ -36,7 +36,7 @@ dependencies {
sqldelight {
databases {
create("SessionDatabase") {
// https://cashapp.github.io/sqldelight/2.0.0/android_sqlite/migrations/
// https://sqldelight.github.io/sqldelight/2.1.0/android_sqlite/migrations/
// To generate a .db file from your latest schema, run this task
// ./gradlew generateDebugSessionDatabaseSchema
// Test migration by running

View file

@ -34,7 +34,7 @@ class DatabaseSessionStore(
private val sessionDataMutex = Mutex()
override fun loggedInStateFlow(): Flow<LoggedInState> {
return database.sessionDataQueries.selectFirst()
return database.sessionDataQueries.selectLatest()
.asFlow()
.mapToOneOrNull(dispatchers.io)
.map {
@ -51,7 +51,17 @@ class DatabaseSessionStore(
override suspend fun addSession(sessionData: SessionData) {
sessionDataMutex.withLock {
database.sessionDataQueries.insertSessionData(sessionData.toDbModel())
val lastUsageIndex = getLastUsageIndex()
database.sessionDataQueries.insertSessionData(
sessionData
.copy(
// position value does not really matter, so just use lastUsageIndex + 1 to ensure that
// the value is always greater than value of any existing account
position = lastUsageIndex + 1,
lastUsageIndex = lastUsageIndex + 1,
)
.toDbModel()
)
}
}
@ -65,18 +75,71 @@ class DatabaseSessionStore(
Timber.e("User ${sessionData.userId} not found in session database")
return
}
// Copy new data from SDK, but keep login timestamp
// Copy new data from SDK, but keep application data
database.sessionDataQueries.updateSession(
sessionData.copy(
loginTimestamp = result.loginTimestamp,
position = result.position,
lastUsageIndex = result.lastUsageIndex,
userDisplayName = result.userDisplayName,
userAvatarUrl = result.userAvatarUrl,
).toDbModel()
)
}
}
override suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?) {
sessionDataMutex.withLock {
val result = database.sessionDataQueries.selectByUserId(sessionId)
.executeAsOneOrNull()
?.toApiModel()
if (result == null) {
Timber.e("User $sessionId not found in session database")
return
}
database.sessionDataQueries.updateSession(
result.copy(
userDisplayName = displayName,
userAvatarUrl = avatarUrl,
).toDbModel()
)
}
}
override suspend fun setLatestSession(sessionId: String) {
val latestSession = getLatestSession()
if (latestSession?.userId == sessionId) {
// Already the latest session
return
}
val lastUsageIndex = latestSession?.lastUsageIndex ?: 0
val result = database.sessionDataQueries.selectByUserId(sessionId)
.executeAsOneOrNull()
?.toApiModel()
if (result == null) {
Timber.e("User $sessionId not found in session database")
return
}
sessionDataMutex.withLock {
// Update lastUsageIndex of the session
database.sessionDataQueries.updateSession(
result.copy(
lastUsageIndex = lastUsageIndex + 1,
).toDbModel()
)
}
}
private fun getLastUsageIndex(): Long {
return database.sessionDataQueries.selectLatest()
.executeAsOneOrNull()
?.lastUsageIndex
?: -1L
}
override suspend fun getLatestSession(): SessionData? {
return sessionDataMutex.withLock {
database.sessionDataQueries.selectFirst()
database.sessionDataQueries.selectLatest()
.executeAsOneOrNull()
?.toApiModel()
}

View file

@ -27,6 +27,10 @@ internal fun SessionData.toDbModel(): DbSessionData {
passphrase = passphrase,
sessionPath = sessionPath,
cachePath = cachePath,
position = position,
lastUsageIndex = lastUsageIndex,
userDisplayName = userDisplayName,
userAvatarUrl = userAvatarUrl,
)
}
@ -45,5 +49,9 @@ internal fun DbSessionData.toApiModel(): SessionData {
passphrase = passphrase,
sessionPath = sessionPath,
cachePath = cachePath,
position = position,
lastUsageIndex = lastUsageIndex,
userDisplayName = userDisplayName,
userAvatarUrl = userAvatarUrl,
)
}

View file

@ -27,15 +27,25 @@ CREATE TABLE SessionData (
-- added in version 6
sessionPath TEXT NOT NULL DEFAULT "",
-- added in version 9
cachePath TEXT NOT NULL DEFAULT ""
cachePath TEXT NOT NULL DEFAULT "",
-- added in version 10
-- position, to be able to sort account by session creation date
position INTEGER NOT NULL DEFAULT 0,
-- index of the last usage session. Each time the current session change, the index of the current
-- session is incremented to the max value + 1 so it becomes the current session
lastUsageIndex INTEGER NOT NULL DEFAULT 0,
-- user display name
userDisplayName TEXT,
-- user avatar url
userAvatarUrl TEXT
);
selectFirst:
SELECT * FROM SessionData LIMIT 1;
selectLatest:
SELECT * FROM SessionData ORDER BY lastUsageIndex DESC LIMIT 1;
selectAll:
SELECT * FROM SessionData;
SELECT * FROM SessionData ORDER BY lastUsageIndex DESC;
selectByUserId:
SELECT * FROM SessionData WHERE userId = ?;

View file

@ -0,0 +1,9 @@
-- Migrate DB from version 9
-- Add position to be able to sort account by session creation date
-- Add lastUsageIndex so we can restore the last session and switch to another one
-- Add display name and avatar url of the user so that we can display a list of accounts.
ALTER TABLE SessionData ADD COLUMN position INTEGER NOT NULL DEFAULT 0;
ALTER TABLE SessionData ADD COLUMN lastUsageIndex INTEGER NOT NULL DEFAULT 0;
ALTER TABLE SessionData ADD COLUMN userDisplayName TEXT;
ALTER TABLE SessionData ADD COLUMN userAvatarUrl TEXT;

View file

@ -13,6 +13,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.session.SessionData
import io.element.android.libraries.sessionstorage.api.LoggedInState
import io.element.android.libraries.sessionstorage.api.LoginType
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
@ -45,11 +46,11 @@ class DatabaseSessionStoreTest {
@Test
fun `addSession persists the SessionData into the DB`() = runTest {
assertThat(database.sessionDataQueries.selectFirst().executeAsOneOrNull()).isNull()
assertThat(database.sessionDataQueries.selectLatest().executeAsOneOrNull()).isNull()
databaseSessionStore.addSession(aSessionData.toApiModel())
assertThat(database.sessionDataQueries.selectFirst().executeAsOneOrNull()).isEqualTo(aSessionData)
assertThat(database.sessionDataQueries.selectLatest().executeAsOneOrNull()).isEqualTo(aSessionData)
assertThat(database.sessionDataQueries.selectAll().executeAsList().size).isEqualTo(1)
}
@ -59,7 +60,12 @@ class DatabaseSessionStoreTest {
assertThat(awaitItem()).isEqualTo(LoggedInState.NotLoggedIn)
databaseSessionStore.addSession(aSessionData.toApiModel())
assertThat(awaitItem()).isEqualTo(LoggedInState.LoggedIn(sessionId = aSessionData.userId, isTokenValid = true))
// TODO add more sessions in multi-account PR.
// Add a second session
databaseSessionStore.addSession(aSessionData.copy(userId = "otherUserId").toApiModel())
assertThat(awaitItem()).isEqualTo(LoggedInState.LoggedIn(sessionId = "otherUserId", isTokenValid = true))
// Remove the second session
databaseSessionStore.removeSession("otherUserId")
assertThat(awaitItem()).isEqualTo(LoggedInState.LoggedIn(sessionId = aSessionData.userId, isTokenValid = true))
// Remove the first session
databaseSessionStore.removeSession(aSessionData.userId)
assertThat(awaitItem()).isEqualTo(LoggedInState.NotLoggedIn)
@ -124,7 +130,83 @@ class DatabaseSessionStoreTest {
}
@Test
fun `update session update all fields except loginTimestamp`() = runTest {
fun `updateUserProfile does nothing if the session is not found`() = runTest {
databaseSessionStore.updateUserProfile(aSessionData.userId, "userDisplayName", "userAvatarUrl")
assertThat(database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOneOrNull()).isNull()
}
@Test
fun `updateUserProfile update the data`() = runTest {
database.sessionDataQueries.insertSessionData(aSessionData)
databaseSessionStore.updateUserProfile(aSessionData.userId, "userDisplayName", "userAvatarUrl")
val updatedSession = database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOne()
assertThat(updatedSession.userDisplayName).isEqualTo("userDisplayName")
assertThat(updatedSession.userAvatarUrl).isEqualTo("userAvatarUrl")
}
@Test
fun `setLatestSession is no op when the session is already the latest session`() = runTest {
database.sessionDataQueries.insertSessionData(aSessionData)
val session = database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOne()
assertThat(session.lastUsageIndex).isEqualTo(0)
assertThat(session.position).isEqualTo(0)
databaseSessionStore.setLatestSession(aSessionData.userId)
assertThat(database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOne().lastUsageIndex).isEqualTo(0)
}
@Test
fun `setLatestSession is no op when the session is not found`() = runTest {
databaseSessionStore.setLatestSession(aSessionData.userId)
}
@Test
fun `multi session test`() = runTest {
databaseSessionStore.addSession(aSessionData.toApiModel())
val session = databaseSessionStore.getSession(aSessionData.userId)!!
assertThat(session.lastUsageIndex).isEqualTo(0)
assertThat(session.position).isEqualTo(0)
val secondSessionData = aSessionData.copy(
userId = "otherUserId",
position = 1,
lastUsageIndex = 1,
)
databaseSessionStore.addSession(secondSessionData.toApiModel())
val secondSession = database.sessionDataQueries.selectByUserId(secondSessionData.userId).executeAsOne()
assertThat(secondSession.lastUsageIndex).isEqualTo(1)
assertThat(secondSession.position).isEqualTo(1)
// Set the first session as the latest
databaseSessionStore.setLatestSession(aSessionData.userId)
val firstSession = database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOne()
assertThat(firstSession.lastUsageIndex).isEqualTo(2)
assertThat(firstSession.position).isEqualTo(0)
// Check that the second session has not been altered
val secondSession2 = database.sessionDataQueries.selectByUserId(secondSessionData.userId).executeAsOne()
assertThat(secondSession2.lastUsageIndex).isEqualTo(1)
assertThat(secondSession2.position).isEqualTo(1)
}
@Test
fun `test sessionsFlow()`() = runTest {
databaseSessionStore.sessionsFlow().test {
assertThat(awaitItem()).isEmpty()
databaseSessionStore.addSession(aSessionData.toApiModel())
assertThat(awaitItem().size).isEqualTo(1)
val secondSessionData = aSessionData.copy(
userId = "otherUserId",
position = 1,
lastUsageIndex = 1,
)
databaseSessionStore.addSession(secondSessionData.toApiModel())
assertThat(awaitItem().size).isEqualTo(2)
databaseSessionStore.removeSession(aSessionData.userId)
assertThat(awaitItem().size).isEqualTo(1)
databaseSessionStore.removeSession(secondSessionData.userId)
assertThat(awaitItem()).isEmpty()
}
}
@Test
fun `update session update all fields except info used by the application`() = runTest {
val firstSessionData = SessionData(
userId = "userId",
deviceId = "deviceId",
@ -139,6 +221,10 @@ class DatabaseSessionStoreTest {
passphrase = "aPassphrase",
sessionPath = "sessionPath",
cachePath = "cachePath",
position = 0,
lastUsageIndex = 0,
userDisplayName = "userDisplayName",
userAvatarUrl = "userAvatarUrl",
)
val secondSessionData = SessionData(
userId = "userId",
@ -152,8 +238,12 @@ class DatabaseSessionStoreTest {
isTokenValid = 1,
loginType = null,
passphrase = "aPassphraseAltered",
sessionPath = "sessionPath",
cachePath = "cachePath",
sessionPath = "sessionPathAltered",
cachePath = "cachePathAltered",
position = 1,
lastUsageIndex = 1,
userDisplayName = "userDisplayNameAltered",
userAvatarUrl = "userAvatarUrlAltered",
)
assertThat(firstSessionData.userId).isEqualTo(secondSessionData.userId)
assertThat(firstSessionData.loginTimestamp).isNotEqualTo(secondSessionData.loginTimestamp)
@ -174,6 +264,11 @@ class DatabaseSessionStoreTest {
assertThat(alteredSession.loginTimestamp).isEqualTo(firstSessionData.loginTimestamp)
assertThat(alteredSession.oidcData).isEqualTo(secondSessionData.oidcData)
assertThat(alteredSession.passphrase).isEqualTo(secondSessionData.passphrase)
// Check that application data have not been altered
assertThat(alteredSession.position).isEqualTo(firstSessionData.position)
assertThat(alteredSession.lastUsageIndex).isEqualTo(firstSessionData.lastUsageIndex)
assertThat(alteredSession.userDisplayName).isEqualTo(firstSessionData.userDisplayName)
assertThat(alteredSession.userAvatarUrl).isEqualTo(firstSessionData.userAvatarUrl)
}
@Test
@ -188,10 +283,14 @@ class DatabaseSessionStoreTest {
loginTimestamp = 1,
oidcData = "aOidcData",
isTokenValid = 1,
loginType = null,
loginType = LoginType.PASSWORD.name,
passphrase = "aPassphrase",
sessionPath = "sessionPath",
cachePath = "cachePath",
position = 0,
lastUsageIndex = 0,
userDisplayName = "userDisplayName",
userAvatarUrl = "userAvatarUrl",
)
val secondSessionData = SessionData(
userId = "userIdUnknown",
@ -203,10 +302,14 @@ class DatabaseSessionStoreTest {
loginTimestamp = 2,
oidcData = "aOidcDataAltered",
isTokenValid = 1,
loginType = null,
loginType = LoginType.PASSWORD.name,
passphrase = "aPassphraseAltered",
sessionPath = "sessionPath",
cachePath = "cachePath",
sessionPath = "sessionPathAltered",
cachePath = "cachePathAltered",
position = 1,
lastUsageIndex = 1,
userDisplayName = "userDisplayNameAltered",
userAvatarUrl = "userAvatarUrlAltered",
)
assertThat(firstSessionData.userId).isNotEqualTo(secondSessionData.userId)
@ -216,14 +319,6 @@ class DatabaseSessionStoreTest {
// Get the session and check that it has not been altered
val notAlteredSession = databaseSessionStore.getSession(firstSessionData.userId)!!.toDbModel()
assertThat(notAlteredSession.userId).isEqualTo(firstSessionData.userId)
assertThat(notAlteredSession.deviceId).isEqualTo(firstSessionData.deviceId)
assertThat(notAlteredSession.accessToken).isEqualTo(firstSessionData.accessToken)
assertThat(notAlteredSession.refreshToken).isEqualTo(firstSessionData.refreshToken)
assertThat(notAlteredSession.homeserverUrl).isEqualTo(firstSessionData.homeserverUrl)
assertThat(notAlteredSession.slidingSyncProxy).isEqualTo(firstSessionData.slidingSyncProxy)
assertThat(notAlteredSession.loginTimestamp).isEqualTo(firstSessionData.loginTimestamp)
assertThat(notAlteredSession.oidcData).isEqualTo(firstSessionData.oidcData)
assertThat(notAlteredSession.passphrase).isEqualTo(firstSessionData.passphrase)
assertThat(notAlteredSession).isEqualTo(firstSessionData)
}
}

View file

@ -24,4 +24,8 @@ internal fun aSessionData() = SessionData(
passphrase = null,
sessionPath = "sessionPath",
cachePath = "cachePath",
position = 0,
lastUsageIndex = 0,
userDisplayName = null,
userAvatarUrl = null,
)

View file

@ -17,6 +17,8 @@ import kotlinx.coroutines.flow.map
class InMemorySessionStore(
initialList: List<SessionData> = emptyList(),
private val updateUserProfileResult: (String, String?, String?) -> Unit = { _, _, _ -> error("Not implemented") },
private val setLatestSessionResult: (String) -> Unit = { error("Not implemented") },
) : SessionStore {
private val sessionDataListFlow = MutableStateFlow(initialList)
@ -53,6 +55,10 @@ class InMemorySessionStore(
}
}
override suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?) {
updateUserProfileResult(sessionId, displayName, avatarUrl)
}
override suspend fun getSession(sessionId: String): SessionData? {
return sessionDataListFlow.value.firstOrNull { it.userId == sessionId }
}
@ -65,6 +71,10 @@ class InMemorySessionStore(
return sessionDataListFlow.value.firstOrNull()
}
override suspend fun setLatestSession(sessionId: String) {
setLatestSessionResult(sessionId)
}
override suspend fun removeSession(sessionId: String) {
val currentList = sessionDataListFlow.value.toMutableList()
currentList.removeAll { it.userId == sessionId }

View file

@ -18,7 +18,11 @@ fun aSessionData(
cachePath: String = "/a/path/to/a/cache",
accessToken: String = "anAccessToken",
refreshToken: String? = "aRefreshToken",
): SessionData {
position: Long = 0,
lastUsageIndex: Long = 0,
userDisplayName: String? = null,
userAvatarUrl: String? = null,
): SessionData {
return SessionData(
userId = sessionId,
deviceId = deviceId,
@ -33,5 +37,9 @@ fun aSessionData(
passphrase = null,
sessionPath = sessionPath,
cachePath = cachePath,
position = position,
lastUsageIndex = lastUsageIndex,
userDisplayName = userDisplayName,
userAvatarUrl = userAvatarUrl,
)
}

View file

@ -13,7 +13,6 @@ import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
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
interface PushHistoryEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
@ -25,6 +24,6 @@ interface PushHistoryEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun onDone()
fun onItemClick(sessionId: SessionId, roomId: RoomId, eventId: EventId)
fun navigateTo(roomId: RoomId, eventId: EventId)
}
}

View file

@ -7,8 +7,13 @@
package io.element.android.libraries.troubleshoot.impl.history
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
sealed interface PushHistoryEvents {
data class SetShowOnlyErrors(val showOnlyErrors: Boolean) : PushHistoryEvents
data class Reset(val requiresConfirmation: Boolean) : PushHistoryEvents
data class NavigateTo(val sessionId: SessionId, val roomId: RoomId, val eventId: EventId) : PushHistoryEvents
data object ClearDialog : PushHistoryEvents
}

View file

@ -20,7 +20,6 @@ import io.element.android.annotations.ContributesNode
import io.element.android.libraries.di.SessionScope
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.troubleshoot.api.PushHistoryEntryPoint
import io.element.android.services.analytics.api.ScreenTracker
@ -29,21 +28,23 @@ import io.element.android.services.analytics.api.ScreenTracker
class PushHistoryNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: PushHistoryPresenter,
presenterFactory: PushHistoryPresenter.Factory,
private val screenTracker: ScreenTracker,
) : Node(buildContext, plugins = plugins) {
) : Node(buildContext, plugins = plugins), PushHistoryNavigator {
private fun onDone() {
plugins<PushHistoryEntryPoint.Callback>().forEach {
it.onDone()
}
}
private fun onItemClick(sessionId: SessionId, roomId: RoomId, eventId: EventId) {
override fun navigateTo(roomId: RoomId, eventId: EventId) {
plugins<PushHistoryEntryPoint.Callback>().forEach {
it.onItemClick(sessionId, roomId, eventId)
it.navigateTo(roomId, eventId)
}
}
private val presenter = presenterFactory.create(this)
@Composable
override fun View(modifier: Modifier) {
screenTracker.TrackScreen(MobileScreen.ScreenName.NotificationTroubleshoot)
@ -51,7 +52,6 @@ class PushHistoryNode(
PushHistoryView(
state = state,
onBackClick = ::onDone,
onItemClick = ::onItemClick,
modifier = modifier,
)
}

View file

@ -14,18 +14,36 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.push.api.PushService
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
@Inject
fun interface PushHistoryNavigator {
fun navigateTo(roomId: RoomId, eventId: EventId)
}
@AssistedInject
class PushHistoryPresenter(
@Assisted private val pushHistoryNavigator: PushHistoryNavigator,
private val pushService: PushService,
matrixClient: MatrixClient,
) : Presenter<PushHistoryState> {
@AssistedFactory
fun interface Factory {
fun create(pushHistoryNavigator: PushHistoryNavigator): PushHistoryPresenter
}
private val sessionId = matrixClient.sessionId
@Composable
override fun present(): PushHistoryState {
val coroutineScope = rememberCoroutineScope()
@ -41,6 +59,7 @@ class PushHistoryPresenter(
}
}.collectAsState(emptyList())
var resetAction: AsyncAction<Unit> by remember { mutableStateOf(AsyncAction.Uninitialized) }
var showNotSameAccountError by remember { mutableStateOf(false) }
fun handleEvents(event: PushHistoryEvents) {
when (event) {
@ -60,6 +79,14 @@ class PushHistoryPresenter(
}
PushHistoryEvents.ClearDialog -> {
resetAction = AsyncAction.Uninitialized
showNotSameAccountError = false
}
is PushHistoryEvents.NavigateTo -> {
if (event.sessionId != sessionId) {
showNotSameAccountError = true
} else {
pushHistoryNavigator.navigateTo(event.roomId, event.eventId)
}
}
}
}
@ -69,6 +96,7 @@ class PushHistoryPresenter(
pushHistoryItems = pushHistory.toImmutableList(),
showOnlyErrors = showOnlyErrors,
resetAction = resetAction,
showNotSameAccountError = showNotSameAccountError,
eventSink = ::handleEvents
)
}

View file

@ -16,5 +16,6 @@ data class PushHistoryState(
val pushHistoryItems: ImmutableList<PushHistoryItem>,
val showOnlyErrors: Boolean,
val resetAction: AsyncAction<Unit>,
val showNotSameAccountError: Boolean,
val eventSink: (PushHistoryEvents) -> Unit,
)

View file

@ -40,6 +40,9 @@ open class PushHistoryStateProvider : PreviewParameterProvider<PushHistoryState>
aPushHistoryState(
resetAction = AsyncAction.ConfirmingNoParams,
),
aPushHistoryState(
showNotSameAccountError = true,
),
)
}
@ -48,12 +51,14 @@ fun aPushHistoryState(
pushHistoryItems: List<PushHistoryItem> = emptyList(),
showOnlyErrors: Boolean = false,
resetAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
showNotSameAccountError: Boolean = false,
eventSink: (PushHistoryEvents) -> Unit = {},
) = PushHistoryState(
pushCounter = pushCounter,
pushHistoryItems = pushHistoryItems.toImmutableList(),
showOnlyErrors = showOnlyErrors,
resetAction = resetAction,
showNotSameAccountError = showNotSameAccountError,
eventSink = eventSink,
)

View file

@ -37,6 +37,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -48,9 +49,6 @@ import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.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.push.api.history.PushHistoryItem
import io.element.android.libraries.troubleshoot.impl.R
import io.element.android.libraries.ui.strings.CommonStrings
@ -60,7 +58,6 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun PushHistoryView(
state: PushHistoryState,
onBackClick: () -> Unit,
onItemClick: (SessionId, RoomId, EventId) -> Unit,
modifier: Modifier = Modifier,
) {
var showMenu by remember { mutableStateOf(false) }
@ -123,7 +120,6 @@ fun PushHistoryView(
.padding(padding)
.consumeWindowInsets(padding),
state = state,
onItemClick = onItemClick,
)
}
@ -142,12 +138,18 @@ fun PushHistoryView(
},
onErrorDismiss = {},
)
if (state.showNotSameAccountError) {
ErrorDialog(
content = "Please switch account first to navigate to the event.",
onSubmit = { state.eventSink(PushHistoryEvents.ClearDialog) }
)
}
}
@Composable
private fun PushHistoryContent(
state: PushHistoryState,
onItemClick: (SessionId, RoomId, EventId) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
@ -173,7 +175,7 @@ private fun PushHistoryContent(
val roomId = pushHistory.roomId
val eventId = pushHistory.eventId
if (sessionId != null && roomId != null && eventId != null) {
onItemClick(sessionId, roomId, eventId)
state.eventSink(PushHistoryEvents.NavigateTo(sessionId, roomId, eventId))
}
}
)
@ -271,6 +273,5 @@ internal fun PushHistoryViewPreview(
PushHistoryView(
state = state,
onBackClick = {},
onItemClick = { _, _, _ -> },
)
}

View file

@ -12,7 +12,7 @@ import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
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.test.FakeMatrixClient
import io.element.android.libraries.push.test.FakePushService
import io.element.android.libraries.troubleshoot.api.PushHistoryEntryPoint
import io.element.android.services.analytics.test.FakeScreenTracker
@ -32,15 +32,21 @@ class DefaultPushHistoryEntryPointTest {
PushHistoryNode(
buildContext = buildContext,
plugins = plugins,
presenter = PushHistoryPresenter(
pushService = FakePushService(),
),
presenterFactory = {
PushHistoryPresenter(
pushHistoryNavigator = object : PushHistoryNavigator {
override fun navigateTo(roomId: RoomId, eventId: EventId) = lambdaError()
},
pushService = FakePushService(),
matrixClient = FakeMatrixClient(),
)
},
screenTracker = FakeScreenTracker(),
)
}
val callback = object : PushHistoryEntryPoint.Callback {
override fun onDone() = lambdaError()
override fun onItemClick(sessionId: SessionId, roomId: RoomId, eventId: EventId) = lambdaError()
override fun navigateTo(roomId: RoomId, eventId: EventId) = lambdaError()
}
val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null))
.callback(callback)

View file

@ -11,9 +11,19 @@ package io.element.android.libraries.troubleshoot.impl.history
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.push.api.PushService
import io.element.android.libraries.push.test.FakePushService
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
@ -29,6 +39,7 @@ class PushHistoryPresenterTest {
assertThat(initialState.pushHistoryItems).isEmpty()
assertThat(initialState.showOnlyErrors).isFalse()
assertThat(initialState.resetAction).isEqualTo(AsyncAction.Uninitialized)
assertThat(initialState.showNotSameAccountError).isFalse()
}
}
@ -119,11 +130,57 @@ class PushHistoryPresenterTest {
}
}
@Test
fun `present - item click current account`() = runTest {
val pushHistoryNavigatorResult = lambdaRecorder<RoomId, EventId, Unit> { _, _ -> }
val presenter = createPushHistoryPresenter(
pushHistoryNavigator = { roomId, eventId ->
pushHistoryNavigatorResult(roomId, eventId)
}
)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(
PushHistoryEvents.NavigateTo(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
eventId = AN_EVENT_ID,
)
)
pushHistoryNavigatorResult.assertions()
.isCalledOnce()
.with(value(A_ROOM_ID), value(AN_EVENT_ID))
}
}
@Test
fun `present - item click not current account`() = runTest {
val presenter = createPushHistoryPresenter()
presenter.test {
val initialState = awaitItem()
initialState.eventSink(
PushHistoryEvents.NavigateTo(
sessionId = A_SESSION_ID_2,
roomId = A_ROOM_ID,
eventId = AN_EVENT_ID,
)
)
assertThat(awaitItem().showNotSameAccountError).isTrue()
// Reset error
initialState.eventSink(PushHistoryEvents.ClearDialog)
assertThat(awaitItem().showNotSameAccountError).isFalse()
}
}
private fun createPushHistoryPresenter(
pushHistoryNavigator: PushHistoryNavigator = PushHistoryNavigator { _, _ -> lambdaError() },
pushService: PushService = FakePushService(),
matrixClient: MatrixClient = FakeMatrixClient(),
): PushHistoryPresenter {
return PushHistoryPresenter(
pushHistoryNavigator = pushHistoryNavigator,
pushService = pushService,
matrixClient = matrixClient,
)
}
}

View file

@ -14,20 +14,14 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
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.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_FORMATTED_DATE
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithThreeParams
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
@ -103,9 +97,8 @@ class PushHistoryViewTest {
}
@Test
fun `clicking on a valid event invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PushHistoryEvents>(expectEvents = false)
val onItemClick = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
fun `clicking on a valid event emits the expected Event`() {
val eventsRecorder = EventsRecorder<PushHistoryEvents>()
rule.setPushHistoryView(
aPushHistoryState(
pushHistoryItems = listOf(
@ -118,25 +111,26 @@ class PushHistoryViewTest {
),
eventSink = eventsRecorder,
),
onItemClick = onItemClick,
)
rule.onNodeWithText(A_FORMATTED_DATE).performClick()
onItemClick.assertions()
.isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID), value(AN_EVENT_ID))
eventsRecorder.assertSingle(
PushHistoryEvents.NavigateTo(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
eventId = AN_EVENT_ID,
)
)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPushHistoryView(
state: PushHistoryState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onItemClick: (SessionId, RoomId, EventId) -> Unit = EnsureNeverCalledWithThreeParams(),
) {
setContent {
PushHistoryView(
state = state,
onBackClick = onBackClick,
onItemClick = onItemClick,
)
}
}