Add Session Verification flow (#197)

This commit is contained in:
Jorge Martin Espinosa 2023-03-17 10:07:19 +01:00 committed by GitHub
parent 1795a844a1
commit dcb98f06aa
76 changed files with 2347 additions and 35 deletions

View file

@ -19,4 +19,6 @@ package io.element.android.features.roomlist.impl
sealed interface RoomListEvents {
data class UpdateFilter(val newFilter: String) : RoomListEvents
data class UpdateVisibleRange(val range: IntRange) : RoomListEvents
object DismissRequestVerificationPrompt : RoomListEvents
object ClearSuccessfulVerificationMessage : RoomListEvents
}

View file

@ -48,6 +48,10 @@ class RoomListNode @AssistedInject constructor(
plugins<RoomListEntryPoint.Callback>().forEach { it.onCreateRoomClicked() }
}
private fun onSessionVerificationClicked() {
plugins<RoomListEntryPoint.Callback>().forEach { it.onSessionVerificationClicked() }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
@ -57,6 +61,7 @@ class RoomListNode @AssistedInject constructor(
onRoomClicked = this::onRoomClicked,
onOpenSettings = this::onOpenSettings,
onCreateRoomClicked = this::onCreateRoomClicked,
onVerifyClicked = this::onSessionVerificationClicked,
)
}
}

View file

@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -35,6 +36,9 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomSummary
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -49,6 +53,7 @@ private const val extendedRangeSize = 40
class RoomListPresenter @Inject constructor(
private val client: MatrixClient,
private val lastMessageFormatter: LastMessageFormatter,
private val sessionVerificationService: SessionVerificationService,
) : Presenter<RoomListState> {
@Composable
@ -71,20 +76,40 @@ class RoomListPresenter @Inject constructor(
initialLoad(matrixUser)
}
// Session verification status (unknown, not verified, verified)
val sessionVerifiedStatus by sessionVerificationService.sessionVerifiedStatus.collectAsState()
var verificationPromptDismissed by rememberSaveable { mutableStateOf(false) }
// We combine both values to only display the prompt if the session is not verified and it wasn't dismissed
val displayVerificationPrompt by remember {
derivedStateOf { sessionVerifiedStatus == SessionVerifiedStatus.NotVerified && !verificationPromptDismissed }
}
// Current verification flow status, if any (initial, requesting, accepted, etc.)
val currentVerificationFlowStatus by sessionVerificationService.verificationFlowState.collectAsState()
// We only care about the 'Finished' state to display the 'verification success' message
val presentVerificationSuccessfulMessage = remember {
derivedStateOf { currentVerificationFlowStatus == VerificationFlowState.Finished }
}
fun handleEvents(event: RoomListEvents) {
when (event) {
is RoomListEvents.UpdateFilter -> filter = event.newFilter
is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range)
RoomListEvents.DismissRequestVerificationPrompt -> verificationPromptDismissed = true
RoomListEvents.ClearSuccessfulVerificationMessage -> sessionVerificationService.reset()
}
}
LaunchedEffect(roomSummaries, filter) {
filteredRoomSummaries.value = updateFilteredRoomSummaries(roomSummaries, filter)
}
return RoomListState(
matrixUser = matrixUser.value,
roomList = filteredRoomSummaries.value,
filter = filter,
presentVerificationSuccessfulMessage = presentVerificationSuccessfulMessage.value,
displayVerificationPrompt = displayVerificationPrompt,
eventSink = ::handleEvents
)
}

View file

@ -26,5 +26,7 @@ data class RoomListState(
val matrixUser: MatrixUser?,
val roomList: ImmutableList<RoomListRoomSummary>,
val filter: String,
val presentVerificationSuccessfulMessage: Boolean,
val displayVerificationPrompt: Boolean,
val eventSink: (RoomListEvents) -> Unit
)

View file

@ -29,6 +29,8 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
override val values: Sequence<RoomListState>
get() = sequenceOf(
aRoomListState(),
aRoomListState().copy(displayVerificationPrompt = true),
aRoomListState().copy(presentVerificationSuccessfulMessage = true),
)
}
@ -36,7 +38,9 @@ internal fun aRoomListState() = RoomListState(
matrixUser = MatrixUser(id = UserId("@id"), username = "User#1", avatarData = AvatarData("@id", "U")),
roomList = aRoomListRoomSummaryList(),
filter = "filter",
eventSink = {}
eventSink = {},
presentVerificationSuccessfulMessage = false,
displayVerificationPrompt = false,
)
internal fun aRoomListRoomSummaryList(): ImmutableList<RoomListRoomSummary> {

View file

@ -16,16 +16,31 @@
package io.element.android.features.roomlist.impl
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Snackbar
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@ -33,17 +48,23 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import io.element.android.features.roomlist.impl.components.RoomListTopBar
import io.element.android.features.roomlist.impl.components.RoomSummaryRow
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.model.MatrixUser
@ -57,40 +78,27 @@ fun RoomListView(
modifier: Modifier = Modifier,
onRoomClicked: (RoomId) -> Unit = {},
onOpenSettings: () -> Unit = {},
onVerifyClicked: () -> Unit = {},
onCreateRoomClicked: () -> Unit = {},
) {
fun onFilterChanged(filter: String) {
state.eventSink(RoomListEvents.UpdateFilter(filter))
}
fun onVisibleRangedChanged(range: IntRange) {
state.eventSink(RoomListEvents.UpdateVisibleRange(range))
}
RoomListContent(
roomSummaries = state.roomList,
matrixUser = state.matrixUser,
filter = state.filter,
state = state,
modifier = modifier,
onRoomClicked = onRoomClicked,
onFilterChanged = ::onFilterChanged,
onOpenSettings = onOpenSettings,
onScrollOver = ::onVisibleRangedChanged,
onVerifyClicked = onVerifyClicked,
onCreateRoomClicked = onCreateRoomClicked,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun RoomListContent(
roomSummaries: ImmutableList<RoomListRoomSummary>,
matrixUser: MatrixUser?,
filter: String,
state: RoomListState,
modifier: Modifier = Modifier,
onVerifyClicked: () -> Unit = {},
onRoomClicked: (RoomId) -> Unit = {},
onFilterChanged: (String) -> Unit = {},
onOpenSettings: () -> Unit = {},
onScrollOver: (IntRange) -> Unit = {},
onCreateRoomClicked: () -> Unit = {},
) {
fun onRoomClicked(room: RoomListRoomSummary) {
@ -117,19 +125,31 @@ fun RoomListContent(
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
onScrollOver(visibleRange)
state.eventSink(RoomListEvents.UpdateVisibleRange(visibleRange))
return super.onPostFling(consumed, available)
}
}
}
val snackbarHostState = remember { SnackbarHostState() }
val verificationCompleteMessage = stringResource(StringR.string.verification_conclusion_ok_self_notice_title)
LaunchedEffect(state.presentVerificationSuccessfulMessage) {
if (state.presentVerificationSuccessfulMessage) {
snackbarHostState.showSnackbar(
message = verificationCompleteMessage,
duration = SnackbarDuration.Short
)
state.eventSink(RoomListEvents.ClearSuccessfulVerificationMessage)
}
}
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
RoomListTopBar(
matrixUser = matrixUser,
filter = filter,
onFilterChanged = onFilterChanged,
matrixUser = state.matrixUser,
filter = state.filter,
onFilterChanged = { state.eventSink(RoomListEvents.UpdateFilter(it)) },
onOpenSettings = onOpenSettings,
scrollBehavior = scrollBehavior,
modifier = Modifier,
@ -146,8 +166,16 @@ fun RoomListContent(
.nestedScroll(nestedScrollConnection),
state = lazyListState,
) {
if (state.displayVerificationPrompt) {
item {
RequestVerificationHeader(
onVerifyClicked = onVerifyClicked,
onDismissClicked = { state.eventSink(RoomListEvents.DismissRequestVerificationPrompt) }
)
}
}
items(
items = roomSummaries,
items = state.roomList,
contentType = { room -> room.contentType() },
) { room ->
RoomSummaryRow(room = room, onClick = ::onRoomClicked)
@ -164,9 +192,80 @@ fun RoomListContent(
Icon(resourceId = DrawableR.drawable.ic_edit_square, contentDescription = stringResource(id = StringR.string.a11y_create_message))
}
},
snackbarHost = {
SnackbarHost (snackbarHostState) { data ->
Snackbar(
snackbarData = data,
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.primary
)
}
},
)
}
@Composable
internal fun RequestVerificationHeader(
onVerifyClicked: () -> Unit,
onDismissClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
Surface(
modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surfaceVariant
) {
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Row {
Text(
stringResource(StringR.string.session_verification_banner_title),
modifier = Modifier.weight(1f),
style = ElementTextStyles.Bold.body,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Start,
)
Icon(
modifier = Modifier.clickable(onClick = onDismissClicked),
imageVector = Icons.Default.Close,
contentDescription = stringResource(StringR.string.action_close)
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(stringResource(StringR.string.session_verification_banner_message), style = ElementTextStyles.Regular.bodyMD)
Spacer(modifier = Modifier.height(12.dp))
Button(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 7.dp),
onClick = onVerifyClicked,
) {
Text(stringResource(StringR.string.session_verification_start), style = ElementTextStyles.Button)
}
}
}
}
}
@Preview
@Composable
internal fun PreviewRequestVerificationHeaderLight() {
ElementPreviewLight {
RequestVerificationHeader(onVerifyClicked = {}, onDismissClicked = {})
}
}
@Preview
@Composable
internal fun PreviewRequestVerificationHeaderDark() {
ElementPreviewDark {
RequestVerificationHeader(onVerifyClicked = {}, onDismissClicked = {})
}
}
private fun RoomListRoomSummary.contentType() = isPlaceholder
@Preview

View file

@ -24,6 +24,8 @@ import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.libraries.dateformatter.api.LastMessageFormatter
import io.element.android.libraries.dateformatter.test.FakeLastMessageFormatter
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_MESSAGE
@ -35,6 +37,7 @@ import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -44,7 +47,8 @@ class RoomListPresenterTests {
fun `present - should start with no user and then load user with success`() = runTest {
val presenter = RoomListPresenter(
FakeMatrixClient(A_SESSION_ID),
createDateFormatter()
createDateFormatter(),
FakeSessionVerificationService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -68,7 +72,8 @@ class RoomListPresenterTests {
userDisplayName = Result.failure(AN_EXCEPTION),
userAvatarURLString = Result.failure(AN_EXCEPTION),
),
createDateFormatter()
createDateFormatter(),
FakeSessionVerificationService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -86,7 +91,8 @@ class RoomListPresenterTests {
fun `present - should filter room with success`() = runTest {
val presenter = RoomListPresenter(
FakeMatrixClient(A_SESSION_ID),
createDateFormatter()
createDateFormatter(),
FakeSessionVerificationService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -108,7 +114,8 @@ class RoomListPresenterTests {
sessionId = A_SESSION_ID,
roomSummaryDataSource = roomSummaryDataSource
),
createDateFormatter()
createDateFormatter(),
FakeSessionVerificationService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -135,7 +142,8 @@ class RoomListPresenterTests {
sessionId = A_SESSION_ID,
roomSummaryDataSource = roomSummaryDataSource
),
createDateFormatter()
createDateFormatter(),
FakeSessionVerificationService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -167,7 +175,8 @@ class RoomListPresenterTests {
sessionId = A_SESSION_ID,
roomSummaryDataSource = roomSummaryDataSource
),
createDateFormatter()
createDateFormatter(),
FakeSessionVerificationService(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -202,6 +211,56 @@ class RoomListPresenterTests {
}
}
@Test
fun `present - handle DismissRequestVerificationPrompt`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource()
val presenter = RoomListPresenter(
FakeMatrixClient(
sessionId = A_SESSION_ID,
roomSummaryDataSource = roomSummaryDataSource
),
createDateFormatter(),
FakeSessionVerificationService().apply {
givenIsReady(true)
givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
},
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val eventSink = awaitItem().eventSink
Truth.assertThat(awaitItem().displayVerificationPrompt).isTrue()
eventSink(RoomListEvents.DismissRequestVerificationPrompt)
Truth.assertThat(awaitItem().displayVerificationPrompt).isFalse()
}
}
@Test
fun `present - presentVerificationSuccessfulMessage & ClearVerificationSuccesfulMessage`() = runTest {
val roomSummaryDataSource = FakeRoomSummaryDataSource()
val presenter = RoomListPresenter(
FakeMatrixClient(
sessionId = A_SESSION_ID,
roomSummaryDataSource = roomSummaryDataSource
),
createDateFormatter(),
FakeSessionVerificationService().apply {
givenIsReady(true)
givenVerificationFlowState(VerificationFlowState.Finished)
},
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
val displayMessageItem = awaitItem()
Truth.assertThat(displayMessageItem.presentVerificationSuccessfulMessage).isTrue()
displayMessageItem.eventSink(RoomListEvents.ClearSuccessfulVerificationMessage)
Truth.assertThat(awaitItem().presentVerificationSuccessfulMessage).isFalse()
}
}
private fun createDateFormatter(): LastMessageFormatter {
return FakeLastMessageFormatter().apply {
givenFormat(A_FORMATTED_DATE)
@ -221,4 +280,3 @@ private val aRoomListRoomSummary = RoomListRoomSummary(
avatarData = AvatarData(id = A_ROOM_ID.value, name = A_ROOM_NAME),
isPlaceholder = false,
)