diff --git a/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt index 4bc3dc06d7..128ac5622c 100644 --- a/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt +++ b/features/userprofile/api/src/main/kotlin/io/element/android/features/userprofile/api/UserProfileState.kt @@ -16,6 +16,7 @@ data class UserProfileState( val userId: UserId, val userName: String?, val avatarUrl: String?, + val isVerified: AsyncData, val isBlocked: AsyncData, val startDmActionState: AsyncAction, val displayConfirmationDialog: ConfirmationDialog?, diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt index e73eef6aa0..e0fba4ba48 100644 --- a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt +++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt @@ -27,6 +27,7 @@ import io.element.android.features.userprofile.api.UserProfileState.Confirmation 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.architecture.runCatchingUpdatingState 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 @@ -75,6 +76,7 @@ class UserProfilePresenter @AssistedInject constructor( var userProfile by remember { mutableStateOf(null) } val startDmActionState: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } val isBlocked: MutableState> = remember { mutableStateOf(AsyncData.Uninitialized) } + val isVerified: MutableState> = remember { mutableStateOf(AsyncData.Uninitialized) } val dmRoomId by getDmRoomId() val canCall by getCanCall(dmRoomId) LaunchedEffect(Unit) { @@ -87,6 +89,11 @@ class UserProfilePresenter @AssistedInject constructor( LaunchedEffect(Unit) { userProfile = client.getProfile(userId).getOrNull() } + LaunchedEffect(Unit) { + suspend { + client.encryptionService().isUserVerified(userId).getOrThrow() + }.runCatchingUpdatingState(isVerified) + } fun handleEvents(event: UserProfileEvents) { when (event) { @@ -126,6 +133,7 @@ class UserProfilePresenter @AssistedInject constructor( userName = userProfile?.displayName, avatarUrl = userProfile?.avatarUrl, isBlocked = isBlocked.value, + isVerified = isVerified.value, startDmActionState = startDmActionState.value, displayConfirmationDialog = confirmationDialog, isCurrentUser = isCurrentUser, diff --git a/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt b/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt index 1da38187e1..e62555d58f 100644 --- a/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt +++ b/features/userprofile/impl/src/test/kotlin/io/element/android/features/userprofile/impl/UserProfilePresenterTest.kt @@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.test.A_THROWABLE 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.FakeMatrixRoom import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.tests.testutils.WarmUpRule @@ -43,7 +44,7 @@ class UserProfilePresenterTest { @Test fun `present - returns the user profile data`() = runTest { val matrixUser = aMatrixUser(A_USER_ID.value, "Alice", "anAvatarUrl") - val client = FakeMatrixClient().apply { + val client = createFakeMatrixClient().apply { givenGetProfileResult(A_USER_ID, Result.success(matrixUser)) } val presenter = createUserProfilePresenter( @@ -55,6 +56,7 @@ class UserProfilePresenterTest { assertThat(initialState.userName).isEqualTo(matrixUser.displayName) assertThat(initialState.avatarUrl).isEqualTo(matrixUser.avatarUrl) assertThat(initialState.isBlocked).isEqualTo(AsyncData.Success(false)) + assertThat(initialState.isVerified.dataOrNull()).isFalse() assertThat(initialState.dmRoomId).isEqualTo(A_ROOM_ID) assertThat(initialState.canCall).isFalse() } @@ -108,7 +110,7 @@ class UserProfilePresenterTest { val room = FakeMatrixRoom( canUserJoinCallResult = { canUserJoinCallResult }, ) - val client = FakeMatrixClient().apply { + val client = createFakeMatrixClient().apply { if (canFindRoom) { givenGetRoomResult(A_ROOM_ID, room) } @@ -126,7 +128,7 @@ class UserProfilePresenterTest { @Test fun `present - returns empty data in case of failure`() = runTest { - val client = FakeMatrixClient().apply { + val client = createFakeMatrixClient().apply { givenGetProfileResult(A_USER_ID, Result.failure(AN_EXCEPTION)) } val presenter = createUserProfilePresenter( @@ -153,14 +155,12 @@ class UserProfilePresenterTest { dialogState.eventSink(UserProfileEvents.ClearConfirmationDialog) assertThat(awaitItem().displayConfirmationDialog).isNull() - - ensureAllEventsConsumed() } } @Test fun `present - BlockUser and UnblockUser without confirmation change the 'blocked' state`() = runTest { - val client = FakeMatrixClient() + val client = createFakeMatrixClient() val presenter = createUserProfilePresenter( client = client, userId = A_USER_ID @@ -181,7 +181,7 @@ class UserProfilePresenterTest { @Test fun `present - BlockUser with error`() = runTest { - val matrixClient = FakeMatrixClient() + val matrixClient = createFakeMatrixClient() matrixClient.givenIgnoreUserResult(Result.failure(A_THROWABLE)) val presenter = createUserProfilePresenter(client = matrixClient) presenter.test { @@ -198,7 +198,7 @@ class UserProfilePresenterTest { @Test fun `present - UnblockUser with error`() = runTest { - val matrixClient = FakeMatrixClient() + val matrixClient = createFakeMatrixClient() matrixClient.givenUnignoreUserResult(Result.failure(A_THROWABLE)) val presenter = createUserProfilePresenter(client = matrixClient) presenter.test { @@ -225,8 +225,6 @@ class UserProfilePresenterTest { dialogState.eventSink(UserProfileEvents.ClearConfirmationDialog) assertThat(awaitItem().displayConfirmationDialog).isNull() - - ensureAllEventsConsumed() } } @@ -262,13 +260,34 @@ class UserProfilePresenterTest { } } + @Test + fun `present - when user is verified, the value in the state is true`() = runTest { + val client = createFakeMatrixClient(isUserVerified = true) + val presenter = createUserProfilePresenter( + client = client, + ) + presenter.test { + assertThat(awaitItem().isVerified.isUninitialized()).isTrue() + assertThat(awaitItem().isVerified.isLoading()).isTrue() + assertThat(awaitItem().isVerified.dataOrNull()).isTrue() + } + } + private suspend fun ReceiveTurbine.awaitFirstItem(): T { - skipItems(1) + skipItems(2) return awaitItem() } + private fun createFakeMatrixClient( + isUserVerified: Boolean = false, + ) = FakeMatrixClient( + encryptionService = FakeEncryptionService( + isUserVerifiedResult = { Result.success(isUserVerified) } + ), + ) + private fun createUserProfilePresenter( - client: MatrixClient = FakeMatrixClient(), + client: MatrixClient = createFakeMatrixClient(), userId: UserId = UserId("@alice:server.org"), startDMAction: StartDMAction = FakeStartDMAction() ): UserProfilePresenter { diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt index abbb9ccdbe..51759d4ae4 100644 --- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt @@ -18,9 +18,14 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.atomic.atoms.MatrixBadgeAtom +import io.element.android.libraries.designsystem.atomic.molecules.MatrixBadgeRowMolecule import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize @@ -30,12 +35,15 @@ import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.testTag +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.toImmutableList @Composable fun UserProfileHeaderSection( avatarUrl: String?, userId: UserId, userName: String?, + isUserVerified: AsyncData, openAvatarPreview: (url: String) -> Unit, modifier: Modifier = Modifier ) { @@ -67,6 +75,17 @@ fun UserProfileHeaderSection( color = MaterialTheme.colorScheme.secondary, textAlign = TextAlign.Center, ) + if (isUserVerified.dataOrNull() == true) { + MatrixBadgeRowMolecule( + data = listOf( + MatrixBadgeAtom.MatrixBadgeData( + text = stringResource(CommonStrings.common_verified), + icon = CompoundIcons.Verified(), + type = MatrixBadgeAtom.Type.Positive, + ) + ).toImmutableList(), + ) + } Spacer(Modifier.height(40.dp)) } } @@ -78,6 +97,7 @@ internal fun UserProfileHeaderSectionPreview() = ElementPreview { avatarUrl = null, userId = UserId("@alice:example.com"), userName = "Alice", + isUserVerified = AsyncData.Success(true), openAvatarPreview = {}, ) } diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt index 9fa1eca2dc..e143a4b5f4 100644 --- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileStateProvider.kt @@ -20,13 +20,12 @@ open class UserProfileStateProvider : PreviewParameterProvider get() = sequenceOf( aUserProfileState(), aUserProfileState(userName = null), - aUserProfileState(isBlocked = AsyncData.Success(true)), + aUserProfileState(isBlocked = AsyncData.Success(true), isVerified = AsyncData.Success(true)), aUserProfileState(displayConfirmationDialog = UserProfileState.ConfirmationDialog.Block), aUserProfileState(displayConfirmationDialog = UserProfileState.ConfirmationDialog.Unblock), - aUserProfileState(isBlocked = AsyncData.Loading(true)), + aUserProfileState(isBlocked = AsyncData.Loading(true), isVerified = AsyncData.Loading()), aUserProfileState(startDmActionState = AsyncAction.Loading), aUserProfileState(canCall = true), - aUserProfileState(dmRoomId = null), // Add other states here ) } @@ -36,6 +35,7 @@ fun aUserProfileState( userName: String? = "Daniel", avatarUrl: String? = null, isBlocked: AsyncData = AsyncData.Success(false), + isVerified: AsyncData = AsyncData.Success(false), startDmActionState: AsyncAction = AsyncAction.Uninitialized, displayConfirmationDialog: UserProfileState.ConfirmationDialog? = null, isCurrentUser: Boolean = false, @@ -47,6 +47,7 @@ fun aUserProfileState( userName = userName, avatarUrl = avatarUrl, isBlocked = isBlocked, + isVerified = isVerified, startDmActionState = startDmActionState, displayConfirmationDialog = displayConfirmationDialog, isCurrentUser = isCurrentUser, diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt index 12aafb2731..c87b443d4a 100644 --- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt @@ -21,6 +21,7 @@ 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.compound.tokens.generated.CompoundIcons import io.element.android.features.userprofile.api.UserProfileEvents import io.element.android.features.userprofile.api.UserProfileState import io.element.android.features.userprofile.shared.blockuser.BlockUserDialogs @@ -28,9 +29,13 @@ import io.element.android.features.userprofile.shared.blockuser.BlockUserSection import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults import io.element.android.libraries.designsystem.components.button.BackButton +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 +import io.element.android.libraries.designsystem.theme.components.IconSource +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.RoomId import io.element.android.libraries.ui.strings.CommonStrings @@ -63,11 +68,11 @@ fun UserProfileView( avatarUrl = state.avatarUrl, userId = state.userId, userName = state.userName, + isUserVerified = state.isVerified, openAvatarPreview = { avatarUrl -> openAvatarPreview(state.userName ?: state.userId.value, avatarUrl) }, ) - UserProfileMainActionsSection( isCurrentUser = state.isCurrentUser, canCall = state.canCall, @@ -75,10 +80,9 @@ fun UserProfileView( onStartDM = { state.eventSink(UserProfileEvents.StartDM) }, onCall = { state.dmRoomId?.let { onStartCall(it) } } ) - Spacer(modifier = Modifier.height(26.dp)) - if (!state.isCurrentUser) { + VerifyUserSection(state) BlockUserSection(state) BlockUserDialogs(state) } @@ -98,6 +102,19 @@ fun UserProfileView( } } +@Composable +private fun VerifyUserSection(state: UserProfileState) { + if (state.isVerified.dataOrNull() == false) { + ListItem( + headlineContent = { Text(stringResource(R.string.screen_room_member_details_verify_button_title, state.userName ?: state.userId)) }, + supportingContent = { Text(stringResource(R.string.screen_room_member_details_verify_button_subtitle)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())), + enabled = false, + onClick = { }, + ) + } +} + @PreviewsDayNight @Composable internal fun UserProfileViewPreview( diff --git a/features/userprofile/shared/src/test/kotlin/io/element/android/features/userprofile/UserProfileViewTest.kt b/features/userprofile/shared/src/test/kotlin/io/element/android/features/userprofile/UserProfileViewTest.kt index 38ebfc7960..6624fb4eba 100644 --- a/features/userprofile/shared/src/test/kotlin/io/element/android/features/userprofile/UserProfileViewTest.kt +++ b/features/userprofile/shared/src/test/kotlin/io/element/android/features/userprofile/UserProfileViewTest.kt @@ -40,6 +40,7 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.junit.runner.RunWith +import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) class UserProfileViewTest { @@ -123,6 +124,7 @@ class UserProfileViewTest { } } + @Config(qualifiers = "h1024dp") @Test fun `on Block user clicked - a BlockUser event is emitted with needsConfirmation`() = runTest { val eventsRecorder = EventsRecorder() @@ -161,6 +163,7 @@ class UserProfileViewTest { eventsRecorder.assertSingle(UserProfileEvents.ClearConfirmationDialog) } + @Config(qualifiers = "h1024dp") @Test fun `on Unblock user clicked - an UnblockUser event is emitted with needsConfirmation`() = runTest { val eventsRecorder = EventsRecorder() diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt index 0bfce8a8d2..b53debcdb2 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt @@ -60,6 +60,8 @@ interface EncryptionService { */ suspend fun startIdentityReset(): Result + suspend fun isUserVerified(userId: UserId): Result + /** * Remember this identity, ensuring it does not result in a pin violation. */ diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt index 69dee9a4d4..31dd6b90a1 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt @@ -38,6 +38,7 @@ import org.matrix.rustcomponents.sdk.BackupSteadyStateListener import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.EnableRecoveryProgressListener import org.matrix.rustcomponents.sdk.Encryption +import org.matrix.rustcomponents.sdk.UserIdentity import org.matrix.rustcomponents.sdk.BackupUploadState as RustBackupUploadState import org.matrix.rustcomponents.sdk.EnableRecoveryProgress as RustEnableRecoveryProgress import org.matrix.rustcomponents.sdk.SteadyStateException as RustSteadyStateException @@ -204,8 +205,18 @@ internal class RustEncryptionService( } } + override suspend fun isUserVerified(userId: UserId): Result = runCatching { + getUserIdentity(userId).isVerified() + } + override suspend fun pinUserIdentity(userId: UserId): Result = runCatching { - val userIdentity = service.userIdentity(userId.value) ?: error("User identity not found") - userIdentity.pin() + getUserIdentity(userId).pin() + } + + private suspend fun getUserIdentity(userId: UserId): UserIdentity { + return service.userIdentity( + userId = userId.value, + // requestFromHomeserverIfNeeded = true, + ) ?: error("User identity not found") } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt index 6778eb5838..5968eda118 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.flowOf class FakeEncryptionService( var startIdentityResetLambda: () -> Result = { lambdaError() }, private val pinUserIdentityResult: (UserId) -> Result = { lambdaError() }, + private val isUserVerifiedResult: (UserId) -> Result = { lambdaError() }, ) : EncryptionService { private var disableRecoveryFailure: Exception? = null override val backupStateStateFlow: MutableStateFlow = MutableStateFlow(BackupState.UNKNOWN) @@ -123,6 +124,10 @@ class FakeEncryptionService( return pinUserIdentityResult(userId) } + override suspend fun isUserVerified(userId: UserId): Result = simulateLongTask { + isUserVerifiedResult(userId) + } + companion object { const val FAKE_RECOVERY_KEY = "fake" }