Merge branch 'develop' into feature/bma/leaveSpace
This commit is contained in:
commit
0e3efafa6d
117 changed files with 2158 additions and 287 deletions
18
libraries/accountselect/api/build.gradle.kts
Normal file
18
libraries/accountselect/api/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
35
libraries/accountselect/impl/build.gradle.kts
Normal file
35
libraries/accountselect/impl/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -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(),
|
||||
)
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
|
|
@ -73,4 +73,6 @@ enum class AvatarSize(val dp: Dp) {
|
|||
RoomPreviewInviter(56.dp),
|
||||
SpaceMember(24.dp),
|
||||
LeaveSpaceRoom(32.dp),
|
||||
|
||||
AccountItem(32.dp),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(""),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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 = ?;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,4 +24,8 @@ internal fun aSessionData() = SessionData(
|
|||
passphrase = null,
|
||||
sessionPath = "sessionPath",
|
||||
cachePath = "cachePath",
|
||||
position = 0,
|
||||
lastUsageIndex = 0,
|
||||
userDisplayName = null,
|
||||
userAvatarUrl = null,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = { _, _, _ -> },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue