First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions
@@ -0,0 +1,53 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.userprofile.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
setupDependencyInjection()
dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.mediaviewer.api)
implementation(projects.features.call.api)
implementation(projects.features.enterprise.api)
implementation(projects.features.verifysession.api)
api(projects.features.userprofile.api)
api(projects.features.userprofile.shared)
implementation(libs.coil.compose)
implementation(projects.features.startchat.api)
implementation(projects.services.analytics.api)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.mediaviewer.test)
testImplementation(projects.features.call.test)
testImplementation(projects.features.verifysession.test)
testImplementation(projects.features.startchat.test)
testImplementation(projects.features.enterprise.test)
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.userprofile.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.userprofile.api.UserProfileEntryPoint
import io.element.android.libraries.architecture.createNode
@ContributesBinding(AppScope::class)
class DefaultUserProfileEntryPoint : UserProfileEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
params: UserProfileEntryPoint.Params,
callback: UserProfileEntryPoint.Callback,
): Node {
return parentNode.createNode<UserProfileFlowNode>(
buildContext = buildContext,
plugins = listOf(params, callback),
)
}
}
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.userprofile.impl
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.userprofile.api.UserProfilePresenterFactory
import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.impl.root.UserProfilePresenter
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.UserId
@ContributesBinding(SessionScope::class)
class DefaultUserProfilePresenterFactory(
private val factory: UserProfilePresenter.Factory,
) : UserProfilePresenterFactory {
override fun create(userId: UserId): Presenter<UserProfileState> = factory.create(userId)
}
@@ -0,0 +1,153 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.userprofile.impl
import android.os.Parcelable
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 com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.userprofile.api.UserProfileEntryPoint
import io.element.android.features.userprofile.impl.root.UserProfileNode
import io.element.android.features.userprofile.shared.UserProfileNodeHelper
import io.element.android.features.verifysession.api.OutgoingVerificationEntryPoint
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
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.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
@AssistedInject
class UserProfileFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val elementCallEntryPoint: ElementCallEntryPoint,
private val sessionId: SessionId,
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
private val outgoingVerificationEntryPoint: OutgoingVerificationEntryPoint,
) : BaseFlowNode<UserProfileFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
sealed interface NavTarget : Parcelable {
@Parcelize
data object Root : NavTarget
@Parcelize
data class AvatarPreview(val name: String, val avatarUrl: String) : NavTarget
@Parcelize
data class VerifyUser(val userId: UserId) : NavTarget
}
private val callback: UserProfileEntryPoint.Callback = callback()
private val inputs = inputs<UserProfileEntryPoint.Params>()
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Root -> {
val callback = object : UserProfileNodeHelper.Callback {
override fun navigateToAvatarPreview(username: String, avatarUrl: String) {
backstack.push(NavTarget.AvatarPreview(username, avatarUrl))
}
override fun navigateToRoom(roomId: RoomId) {
callback.navigateToRoom(roomId)
}
override fun startCall(dmRoomId: RoomId) {
elementCallEntryPoint.startCall(CallType.RoomCall(sessionId = sessionId, roomId = dmRoomId))
}
override fun startVerifyUserFlow(userId: UserId) {
backstack.push(NavTarget.VerifyUser(userId))
}
}
val params = UserProfileNode.UserProfileInputs(userId = inputs.userId)
createNode<UserProfileNode>(buildContext, listOf(callback, params))
}
is NavTarget.AvatarPreview -> {
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() {
backstack.pop()
}
override fun viewInTimeline(eventId: EventId) {
// Cannot happen
}
override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) {
// Cannot happen
}
}
val params = mediaViewerEntryPoint.createParamsForAvatar(
filename = navTarget.name,
avatarUrl = navTarget.avatarUrl,
)
mediaViewerEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = params,
callback = callback,
)
}
is NavTarget.VerifyUser -> {
val params = OutgoingVerificationEntryPoint.Params(
showDeviceVerifiedScreen = false,
verificationRequest = VerificationRequest.Outgoing.User(userId = navTarget.userId)
)
outgoingVerificationEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = params,
callback = object : OutgoingVerificationEntryPoint.Callback {
override fun navigateToLearnMoreAboutEncryption() {
// No op
}
override fun onBack() {
// No op
}
override fun onDone() {
// No op
}
}
)
}
}
}
@Composable
override fun View(modifier: Modifier) {
BackstackView()
}
}
@@ -0,0 +1,83 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.userprofile.impl.root
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.lifecycle.subscribe
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.Assisted
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.annotations.ContributesNode
import io.element.android.features.userprofile.shared.UserProfileNodeHelper
import io.element.android.features.userprofile.shared.UserProfileView
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder
import io.element.android.services.analytics.api.AnalyticsService
@ContributesNode(SessionScope::class)
@AssistedInject
class UserProfileNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val analyticsService: AnalyticsService,
private val permalinkBuilder: PermalinkBuilder,
presenterFactory: UserProfilePresenter.Factory,
) : Node(buildContext, plugins = plugins) {
data class UserProfileInputs(
val userId: UserId
) : NodeInputs
private val inputs = inputs<UserProfileInputs>()
private val callback = inputs<UserProfileNodeHelper.Callback>()
private val presenter = presenterFactory.create(userId = inputs.userId)
private val userProfileNodeHelper = UserProfileNodeHelper(inputs.userId)
init {
lifecycle.subscribe(
onResume = {
analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.User))
}
)
}
@Composable
override fun View(modifier: Modifier) {
val context = LocalContext.current
fun onShareUser() {
userProfileNodeHelper.onShareUser(context, permalinkBuilder)
}
fun onStartDM(roomId: RoomId) {
callback.navigateToRoom(roomId)
}
val state = presenter.present()
UserProfileView(
state = state,
modifier = modifier,
goBack = this::navigateUp,
onShareUser = ::onShareUser,
onOpenDm = ::onStartDM,
onStartCall = callback::startCall,
openAvatarPreview = callback::navigateToAvatarPreview,
onVerifyClick = callback::startVerifyUserFlow,
)
}
}
@@ -0,0 +1,180 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.userprofile.impl.root
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.enterprise.api.SessionEnterpriseService
import io.element.android.features.startchat.api.StartDMAction
import io.element.android.features.userprofile.api.UserProfileEvents
import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.api.UserProfileState.ConfirmationDialog
import io.element.android.features.userprofile.api.UserProfileVerificationState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@AssistedInject
class UserProfilePresenter(
@Assisted private val userId: UserId,
private val client: MatrixClient,
private val startDMAction: StartDMAction,
private val sessionEnterpriseService: SessionEnterpriseService,
) : Presenter<UserProfileState> {
@AssistedFactory
interface Factory {
fun create(userId: UserId): UserProfilePresenter
}
@Composable
private fun getDmRoomId(): State<RoomId?> {
return produceState<RoomId?>(initialValue = null) {
value = client.findDM(userId).getOrNull()
}
}
@Composable
private fun getCanCall(roomId: RoomId?): State<Boolean> {
val isElementCallAvailable by produceState(initialValue = false, roomId) {
value = sessionEnterpriseService.isElementCallAvailable()
}
return produceState(initialValue = false, isElementCallAvailable, roomId) {
value = when {
isElementCallAvailable.not() -> false
client.isMe(userId) -> false
else ->
roomId
?.let { client.getRoom(it) }
?.use { room ->
room.canUserJoinCall(client.sessionId).getOrNull()
}
.orFalse()
}
}
}
@Composable
override fun present(): UserProfileState {
val coroutineScope = rememberCoroutineScope()
val isCurrentUser = remember { client.isMe(userId) }
var confirmationDialog by remember { mutableStateOf<ConfirmationDialog?>(null) }
val startDmActionState: MutableState<AsyncAction<RoomId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val isBlocked: MutableState<AsyncData<Boolean>> = remember { mutableStateOf(AsyncData.Uninitialized) }
val dmRoomId by getDmRoomId()
val canCall by getCanCall(dmRoomId)
LaunchedEffect(Unit) {
client.ignoredUsersFlow
.map { ignoredUsers -> userId in ignoredUsers }
.distinctUntilChanged()
.onEach { isBlocked.value = AsyncData.Success(it) }
.launchIn(this)
}
val userProfile by produceState<MatrixUser?>(null) { value = client.getProfile(userId).getOrNull() }
fun handleEvent(event: UserProfileEvents) {
when (event) {
is UserProfileEvents.BlockUser -> {
if (event.needsConfirmation) {
confirmationDialog = ConfirmationDialog.Block
} else {
confirmationDialog = null
coroutineScope.blockUser(isBlocked)
}
}
is UserProfileEvents.UnblockUser -> {
if (event.needsConfirmation) {
confirmationDialog = ConfirmationDialog.Unblock
} else {
confirmationDialog = null
coroutineScope.unblockUser(isBlocked)
}
}
UserProfileEvents.ClearConfirmationDialog -> confirmationDialog = null
UserProfileEvents.ClearBlockUserError -> {
isBlocked.value = AsyncData.Success(isBlocked.value.dataOrNull().orFalse())
}
UserProfileEvents.StartDM -> {
coroutineScope.launch {
startDMAction.execute(
matrixUser = userProfile ?: MatrixUser(userId),
createIfDmDoesNotExist = startDmActionState.value is AsyncAction.Confirming,
actionState = startDmActionState,
)
}
}
UserProfileEvents.ClearStartDMState -> {
startDmActionState.value = AsyncAction.Uninitialized
}
// Do nothing for other event as they are handled by the RoomMemberDetailsPresenter if needed
UserProfileEvents.WithdrawVerification,
is UserProfileEvents.CopyToClipboard -> Unit
}
}
return UserProfileState(
userId = userId,
userName = userProfile?.displayName,
avatarUrl = userProfile?.avatarUrl,
isBlocked = isBlocked.value,
verificationState = UserProfileVerificationState.UNKNOWN,
startDmActionState = startDmActionState.value,
displayConfirmationDialog = confirmationDialog,
isCurrentUser = isCurrentUser,
dmRoomId = dmRoomId,
canCall = canCall,
snackbarMessage = null,
eventSink = ::handleEvent,
)
}
private fun CoroutineScope.blockUser(
isBlockedState: MutableState<AsyncData<Boolean>>,
) = launch {
isBlockedState.value = AsyncData.Loading(false)
client.ignoreUser(userId)
.onFailure {
isBlockedState.value = AsyncData.Failure(it, false)
}
// Note: on success, ignoredUsersFlow will emit new item.
}
private fun CoroutineScope.unblockUser(
isBlockedState: MutableState<AsyncData<Boolean>>,
) = launch {
isBlockedState.value = AsyncData.Loading(true)
client.unignoreUser(userId)
.onFailure {
isBlockedState.value = AsyncData.Failure(it, true)
}
// Note: on success, ignoredUsersFlow will emit new item.
}
}
@@ -0,0 +1,66 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.userprofile.impl
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.test.FakeElementCallEntryPoint
import io.element.android.features.userprofile.api.UserProfileEntryPoint
import io.element.android.features.verifysession.test.FakeOutgoingVerificationEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.mediaviewer.test.FakeMediaViewerEntryPoint
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 DefaultUserProfileEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `test node builder`() {
val entryPoint = DefaultUserProfileEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
UserProfileFlowNode(
buildContext = buildContext,
plugins = plugins,
sessionId = A_SESSION_ID,
elementCallEntryPoint = FakeElementCallEntryPoint(),
mediaViewerEntryPoint = FakeMediaViewerEntryPoint(),
outgoingVerificationEntryPoint = FakeOutgoingVerificationEntryPoint(),
)
}
val callback = object : UserProfileEntryPoint.Callback {
override fun navigateToRoom(roomId: RoomId) {
lambdaError()
}
}
val params = UserProfileEntryPoint.Params(
userId = A_USER_ID,
)
val result = entryPoint.createNode(
parentNode = parentNode,
buildContext = BuildContext.root(null),
params = params,
callback = callback,
)
assertThat(result).isInstanceOf(UserProfileFlowNode::class.java)
assertThat(result.plugins).contains(params)
assertThat(result.plugins).contains(callback)
}
}
@@ -0,0 +1,417 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.userprofile.impl
import androidx.compose.runtime.MutableState
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.FakeSessionEnterpriseService
import io.element.android.features.invitepeople.test.FakeStartDMAction
import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser
import io.element.android.features.startchat.api.StartDMAction
import io.element.android.features.userprofile.api.UserProfileEvents
import io.element.android.features.userprofile.api.UserProfileState
import io.element.android.features.userprofile.api.UserProfileVerificationState
import io.element.android.features.userprofile.impl.root.UserProfilePresenter
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.any
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.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@ExperimentalCoroutinesApi
class UserProfilePresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - returns the user profile data`() = runTest {
val matrixUser = aMatrixUser(A_USER_ID.value, "Alice", "anAvatarUrl")
val client = createFakeMatrixClient().apply {
givenGetProfileResult(A_USER_ID, Result.success(matrixUser))
}
val presenter = createUserProfilePresenter(
client = client,
)
presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.userId).isEqualTo(matrixUser.userId)
assertThat(initialState.userName).isEqualTo(matrixUser.displayName)
assertThat(initialState.avatarUrl).isEqualTo(matrixUser.avatarUrl)
assertThat(initialState.isBlocked).isEqualTo(AsyncData.Success(false))
assertThat(initialState.verificationState).isEqualTo(UserProfileVerificationState.UNKNOWN)
assertThat(initialState.dmRoomId).isEqualTo(A_ROOM_ID)
assertThat(initialState.canCall).isFalse()
}
}
@Test
fun `present - canCall is true when all the conditions are met`() {
testCanCall(
expectedResult = true,
skipItems = 3,
checkThatRoomIsDestroyed = true,
)
}
@Test
fun `present - canCall is false when canUserJoinCall returns false`() {
testCanCall(
canUserJoinCallResult = Result.success(false),
expectedResult = false,
)
}
@Test
fun `present - canCall is false when canUserJoinCall fails`() {
testCanCall(
canUserJoinCallResult = Result.failure(AN_EXCEPTION),
expectedResult = false,
)
}
@Test
fun `present - canCall is false when there is no DM`() {
testCanCall(
dmRoom = null,
expectedResult = false,
)
}
@Test
fun `present - canCall is false when room is not found`() {
testCanCall(
canFindRoom = false,
expectedResult = false,
)
}
@Test
fun `present - canCall is false when call is not available`() {
testCanCall(
isElementCallAvailable = false,
expectedResult = false,
)
}
private fun testCanCall(
isElementCallAvailable: Boolean = true,
canUserJoinCallResult: Result<Boolean> = Result.success(true),
dmRoom: RoomId? = A_ROOM_ID,
canFindRoom: Boolean = true,
expectedResult: Boolean,
skipItems: Int = 1,
checkThatRoomIsDestroyed: Boolean = false,
) = runTest {
val room = FakeBaseRoom(
canUserJoinCallResult = { canUserJoinCallResult },
)
val client = createFakeMatrixClient().apply {
if (canFindRoom) {
givenGetRoomResult(A_ROOM_ID, room)
}
givenFindDmResult(Result.success(dmRoom))
}
val presenter = createUserProfilePresenter(
userId = A_USER_ID_2,
client = client,
isElementCallAvailable = isElementCallAvailable,
)
presenter.test {
val initialState = awaitFirstItem(skipItems)
assertThat(initialState.canCall).isEqualTo(expectedResult)
}
if (checkThatRoomIsDestroyed) {
room.assertDestroyed()
}
}
@Test
fun `present - returns empty data in case of failure`() = runTest {
val client = createFakeMatrixClient().apply {
givenGetProfileResult(A_USER_ID, Result.failure(AN_EXCEPTION))
}
val presenter = createUserProfilePresenter(
client = client,
)
presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.userId).isEqualTo(A_USER_ID)
assertThat(initialState.userName).isNull()
assertThat(initialState.avatarUrl).isNull()
assertThat(initialState.isBlocked).isEqualTo(AsyncData.Success(false))
}
}
@Test
fun `present - BlockUser needing confirmation displays confirmation dialog`() = runTest {
val presenter = createUserProfilePresenter()
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(UserProfileEvents.BlockUser(needsConfirmation = true))
val dialogState = awaitItem()
assertThat(dialogState.displayConfirmationDialog).isEqualTo(UserProfileState.ConfirmationDialog.Block)
dialogState.eventSink(UserProfileEvents.ClearConfirmationDialog)
assertThat(awaitItem().displayConfirmationDialog).isNull()
}
}
@Test
fun `present - BlockUser and UnblockUser without confirmation change the 'blocked' state`() = runTest {
val ignoredUsersFlow = MutableStateFlow(persistentListOf<UserId>())
val client = createFakeMatrixClient(ignoredUsersFlow = ignoredUsersFlow)
val presenter = createUserProfilePresenter(
client = client,
userId = A_USER_ID
)
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(UserProfileEvents.BlockUser(needsConfirmation = false))
assertThat(awaitItem().isBlocked.isLoading()).isTrue()
ignoredUsersFlow.emit(persistentListOf(A_USER_ID))
assertThat(awaitItem().isBlocked.dataOrNull()).isTrue()
initialState.eventSink(UserProfileEvents.UnblockUser(needsConfirmation = false))
assertThat(awaitItem().isBlocked.isLoading()).isTrue()
ignoredUsersFlow.emit(persistentListOf())
assertThat(awaitItem().isBlocked.dataOrNull()).isFalse()
}
}
@Test
fun `present - BlockUser with error`() = runTest {
val matrixClient = createFakeMatrixClient(
ignoreUserResult = { Result.failure(AN_EXCEPTION) }
)
val presenter = createUserProfilePresenter(client = matrixClient)
presenter.test {
val initialState = awaitFirstItem(count = 2)
initialState.eventSink(UserProfileEvents.BlockUser(needsConfirmation = false))
assertThat(awaitItem().isBlocked.isLoading()).isTrue()
val errorState = awaitItem()
assertThat(errorState.isBlocked.errorOrNull()).isEqualTo(AN_EXCEPTION)
// Clear error
initialState.eventSink(UserProfileEvents.ClearBlockUserError)
assertThat(awaitItem().isBlocked).isEqualTo(AsyncData.Success(false))
}
}
@Test
fun `present - UnblockUser with error`() = runTest {
val matrixClient = createFakeMatrixClient(
unIgnoreUserResult = { Result.failure(AN_EXCEPTION) }
)
val presenter = createUserProfilePresenter(client = matrixClient)
presenter.test {
val initialState = awaitFirstItem(count = 2)
initialState.eventSink(UserProfileEvents.UnblockUser(needsConfirmation = false))
assertThat(awaitItem().isBlocked.isLoading()).isTrue()
val errorState = awaitItem()
assertThat(errorState.isBlocked.errorOrNull()).isEqualTo(AN_EXCEPTION)
// Clear error
initialState.eventSink(UserProfileEvents.ClearBlockUserError)
assertThat(awaitItem().isBlocked).isEqualTo(AsyncData.Success(true))
}
}
@Test
fun `present - UnblockUser needing confirmation displays confirmation dialog`() = runTest {
val presenter = createUserProfilePresenter()
presenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(UserProfileEvents.UnblockUser(needsConfirmation = true))
val dialogState = awaitItem()
assertThat(dialogState.displayConfirmationDialog).isEqualTo(UserProfileState.ConfirmationDialog.Unblock)
dialogState.eventSink(UserProfileEvents.ClearConfirmationDialog)
assertThat(awaitItem().displayConfirmationDialog).isNull()
}
}
@Test
fun `present - start DM action failure scenario`() = runTest {
val startDMFailureResult = AsyncAction.Failure(AN_EXCEPTION)
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
actionState.value = startDMFailureResult
}
val startDMAction = FakeStartDMAction(executeResult = executeResult)
val presenter = createUserProfilePresenter(startDMAction = startDMAction)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
assertThat(initialState.startDmActionState).isInstanceOf(AsyncAction.Uninitialized::class.java)
val matrixUser = MatrixUser(UserId("@alice:server.org"))
initialState.eventSink(UserProfileEvents.StartDM)
awaitItem().also { state ->
assertThat(state.startDmActionState).isEqualTo(startDMFailureResult)
executeResult.assertions().isCalledOnce().with(
value(matrixUser),
value(false),
any(),
)
state.eventSink(UserProfileEvents.ClearStartDMState)
}
awaitItem().also { state ->
assertThat(state.startDmActionState.isUninitialized()).isTrue()
}
}
}
@Test
fun `present - start DM action success scenario`() = runTest {
val startDMSuccessResult = AsyncAction.Success(A_ROOM_ID)
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
actionState.value = startDMSuccessResult
}
val startDMAction = FakeStartDMAction(executeResult = executeResult)
val presenter = createUserProfilePresenter(startDMAction = startDMAction)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
assertThat(initialState.startDmActionState).isInstanceOf(AsyncAction.Uninitialized::class.java)
val matrixUser = MatrixUser(UserId("@alice:server.org"))
initialState.eventSink(UserProfileEvents.StartDM)
awaitItem().also { state ->
assertThat(state.startDmActionState).isEqualTo(startDMSuccessResult)
executeResult.assertions().isCalledOnce().with(
value(matrixUser),
value(false),
any(),
)
}
}
}
@Test
fun `present - start DM action confirmation scenario - cancel`() = runTest {
val matrixUser = MatrixUser(UserId("@alice:server.org"))
val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser)
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
actionState.value = startDMConfirmationResult
}
val startDMAction = FakeStartDMAction(executeResult = executeResult)
val presenter = createUserProfilePresenter(startDMAction = startDMAction)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
assertThat(initialState.startDmActionState).isInstanceOf(AsyncAction.Uninitialized::class.java)
initialState.eventSink(UserProfileEvents.StartDM)
val confirmingState = awaitItem()
assertThat(confirmingState.startDmActionState).isEqualTo(startDMConfirmationResult)
executeResult.assertions().isCalledOnce().with(
value(matrixUser),
value(false),
any(),
)
// Cancelling should not create the DM
confirmingState.eventSink(UserProfileEvents.ClearStartDMState)
val finalState = awaitItem()
assertThat(finalState.startDmActionState.isUninitialized()).isTrue()
executeResult.assertions().isCalledExactly(1)
}
}
@Test
fun `present - start DM action confirmation scenario - confirm`() = runTest {
val matrixUser = MatrixUser(UserId("@alice:server.org"))
val startDMConfirmationResult = ConfirmingStartDmWithMatrixUser(matrixUser)
val executeResult = lambdaRecorder<MatrixUser, Boolean, MutableState<AsyncAction<RoomId>>, Unit> { _, _, actionState ->
actionState.value = startDMConfirmationResult
}
val startDMAction = FakeStartDMAction(executeResult = executeResult)
val presenter = createUserProfilePresenter(startDMAction = startDMAction)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitFirstItem()
assertThat(initialState.startDmActionState).isInstanceOf(AsyncAction.Uninitialized::class.java)
initialState.eventSink(UserProfileEvents.StartDM)
val confirmingState = awaitItem()
assertThat(confirmingState.startDmActionState).isEqualTo(startDMConfirmationResult)
executeResult.assertions().isCalledOnce().with(
value(matrixUser),
value(false),
any(),
)
// Start DM again should invoke the action with createIfDmDoesNotExist = true
confirmingState.eventSink(UserProfileEvents.StartDM)
executeResult.assertions().isCalledExactly(2).withSequence(
listOf(value(matrixUser), value(false), any()),
listOf(value(matrixUser), value(true), any()),
)
}
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(count: Int = 1): T {
skipItems(count)
return awaitItem()
}
private fun createFakeMatrixClient(
userIdentityState: IdentityState? = null,
ignoreUserResult: (UserId) -> Result<Unit> = { Result.success(Unit) },
unIgnoreUserResult: (UserId) -> Result<Unit> = { Result.success(Unit) },
ignoredUsersFlow: StateFlow<ImmutableList<UserId>> = MutableStateFlow(persistentListOf())
) = FakeMatrixClient(
encryptionService = FakeEncryptionService(
getUserIdentityResult = { Result.success(userIdentityState) }
),
ignoreUserResult = ignoreUserResult,
unIgnoreUserResult = unIgnoreUserResult,
ignoredUsersFlow = ignoredUsersFlow,
)
private fun createUserProfilePresenter(
client: MatrixClient = createFakeMatrixClient(),
userId: UserId = UserId("@alice:server.org"),
startDMAction: StartDMAction = FakeStartDMAction(),
isElementCallAvailable: Boolean = true,
): UserProfilePresenter {
return UserProfilePresenter(
userId = userId,
client = client,
startDMAction = startDMAction,
sessionEnterpriseService = FakeSessionEnterpriseService(
isElementCallAvailableResult = { isElementCallAvailable },
),
)
}
}