Move session verification to FTUE flow, make it mandatory (#2594)

* Move session verification to the FTUE
* Allow session verification flow to be restarted
* Use `EncryptionService` to display session verification faster
* Remove session verification item from settings
* Remove session verification banner from room list
* Remove 'verification needed' variant from the `TimelineEncryptedHistoryBanner`
* Improve verification flow UI and UX
* Remove 'verification successful' snackbar message
* Only register push provider after the session has been verified
* Hide room list while the session hasn't been verified
* Prevent deep links from changing the navigation if the session isn't verified
* Update screenshots
* Renamed `FtueState` to `FtueService`, created an actual `FtueState`.

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Jorge Martin Espinosa 2024-04-03 16:53:17 +02:00 committed by GitHub
parent 05f6770d35
commit 41287c5f59
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
198 changed files with 822 additions and 761 deletions

View file

@ -64,10 +64,6 @@ class RoomListNode @AssistedInject constructor(
plugins<RoomListEntryPoint.Callback>().forEach { it.onCreateRoomClicked() }
}
private fun onSessionVerificationClicked() {
plugins<RoomListEntryPoint.Callback>().forEach { it.onSessionVerificationClicked() }
}
private fun onSessionConfirmRecoveryKeyClicked() {
plugins<RoomListEntryPoint.Callback>().forEach { it.onSessionConfirmRecoveryKeyClicked() }
}
@ -104,7 +100,6 @@ class RoomListNode @AssistedInject constructor(
onRoomClicked = this::onRoomClicked,
onSettingsClicked = this::onOpenSettings,
onCreateRoomClicked = this::onCreateRoomClicked,
onVerifyClicked = this::onSessionVerificationClicked,
onConfirmRecoveryKeyClicked = this::onSessionConfirmRecoveryKeyClicked,
onInvitesClicked = this::onInvitesClicked,
onRoomSettingsClicked = this::onRoomSettingsClicked,

View file

@ -58,7 +58,6 @@ import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.toPersistentList
@ -92,7 +91,6 @@ class RoomListPresenter @Inject constructor(
private val analyticsService: AnalyticsService,
) : Presenter<RoomListState> {
private val encryptionService: EncryptionService = client.encryptionService()
private val sessionVerificationService: SessionVerificationService = client.sessionVerificationService()
private val syncService: SyncService = client.syncService()
@Composable
@ -159,19 +157,12 @@ class RoomListPresenter @Inject constructor(
securityBannerDismissed: Boolean,
): State<SecurityBannerState> {
val currentSecurityBannerDismissed by rememberUpdatedState(securityBannerDismissed)
val canVerifySession by sessionVerificationService.canVerifySessionFlow.collectAsState(initial = false)
val isLastDevice by encryptionService.isLastDevice.collectAsState()
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
val syncState by syncService.syncState.collectAsState()
return remember {
derivedStateOf {
when {
currentSecurityBannerDismissed -> SecurityBannerState.None
canVerifySession -> if (isLastDevice) {
SecurityBannerState.RecoveryKeyConfirmation
} else {
SecurityBannerState.SessionVerification
}
recoveryState == RecoveryState.INCOMPLETE &&
syncState == SyncState.Running -> SecurityBannerState.RecoveryKeyConfirmation
else -> SecurityBannerState.None

View file

@ -63,7 +63,6 @@ enum class InvitesState {
enum class SecurityBannerState {
None,
SessionVerification,
RecoveryKeyConfirmation,
}

View file

@ -45,7 +45,6 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
aRoomListState(contentState = aRoomsContentState(invitesState = InvitesState.NewInvites)),
aRoomListState(contextMenu = aContextMenuShown(roomName = "A nice room name")),
aRoomListState(contextMenu = aContextMenuShown(isFavorite = true)),
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SessionVerification)),
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)),
aRoomListState(contentState = anEmptyContentState()),
aRoomListState(contentState = aSkeletonContentState()),

View file

@ -53,7 +53,6 @@ fun RoomListView(
state: RoomListState,
onRoomClicked: (RoomId) -> Unit,
onSettingsClicked: () -> Unit,
onVerifyClicked: () -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit,
onCreateRoomClicked: () -> Unit,
onInvitesClicked: () -> Unit,
@ -86,7 +85,6 @@ fun RoomListView(
RoomListScaffold(
modifier = Modifier.padding(top = topPadding),
state = state,
onVerifyClicked = onVerifyClicked,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onRoomClicked = onRoomClicked,
onRoomLongClicked = { onRoomLongClicked(it) },
@ -115,7 +113,6 @@ fun RoomListView(
@Composable
private fun RoomListScaffold(
state: RoomListState,
onVerifyClicked: () -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit,
onRoomClicked: (RoomId) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
@ -154,7 +151,6 @@ private fun RoomListScaffold(
contentState = state.contentState,
filtersState = state.filtersState,
eventSink = state.eventSink,
onVerifyClicked = onVerifyClicked,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onRoomClicked = ::onRoomClicked,
onRoomLongClicked = onRoomLongClicked,
@ -193,7 +189,6 @@ internal fun RoomListViewPreview(@PreviewParameter(RoomListStateProvider::class)
state = state,
onRoomClicked = {},
onSettingsClicked = {},
onVerifyClicked = {},
onConfirmRecoveryKeyClicked = {},
onCreateRoomClicked = {},
onInvitesClicked = {},

View file

@ -1,49 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomlist.impl.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.features.roomlist.impl.R
import io.element.android.libraries.designsystem.atomic.molecules.DialogLikeBannerMolecule
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@Composable
internal fun RequestVerificationHeader(
onVerifyClicked: () -> Unit,
onDismissClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
DialogLikeBannerMolecule(
modifier = modifier,
title = stringResource(R.string.session_verification_banner_title),
content = stringResource(R.string.session_verification_banner_message),
onSubmitClicked = onVerifyClicked,
onDismissClicked = onDismissClicked,
)
}
@PreviewsDayNight
@Composable
internal fun RequestVerificationHeaderPreview() = ElementPreview {
RequestVerificationHeader(
onVerifyClicked = {},
onDismissClicked = {},
)
}

View file

@ -73,7 +73,6 @@ fun RoomListContentView(
contentState: RoomListContentState,
filtersState: RoomListFiltersState,
eventSink: (RoomListEvents) -> Unit,
onVerifyClicked: () -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit,
onRoomClicked: (RoomListRoomSummary) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
@ -103,7 +102,6 @@ fun RoomListContentView(
state = contentState,
filtersState = filtersState,
eventSink = eventSink,
onVerifyClicked = onVerifyClicked,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onRoomClicked = onRoomClicked,
onRoomLongClicked = onRoomLongClicked,
@ -161,7 +159,6 @@ private fun RoomsView(
state: RoomListContentState.Rooms,
filtersState: RoomListFiltersState,
eventSink: (RoomListEvents) -> Unit,
onVerifyClicked: () -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit,
onRoomClicked: (RoomListRoomSummary) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
@ -177,7 +174,6 @@ private fun RoomsView(
RoomsViewList(
state = state,
eventSink = eventSink,
onVerifyClicked = onVerifyClicked,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onRoomClicked = onRoomClicked,
onRoomLongClicked = onRoomLongClicked,
@ -191,7 +187,6 @@ private fun RoomsView(
private fun RoomsViewList(
state: RoomListContentState.Rooms,
eventSink: (RoomListEvents) -> Unit,
onVerifyClicked: () -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit,
onRoomClicked: (RoomListRoomSummary) -> Unit,
onRoomLongClicked: (RoomListRoomSummary) -> Unit,
@ -222,14 +217,6 @@ private fun RoomsViewList(
contentPadding = PaddingValues(bottom = 80.dp)
) {
when (state.securityBannerState) {
SecurityBannerState.SessionVerification -> {
item {
RequestVerificationHeader(
onVerifyClicked = onVerifyClicked,
onDismissClicked = { eventSink(RoomListEvents.DismissRequestVerificationPrompt) }
)
}
}
SecurityBannerState.RecoveryKeyConfirmation -> {
item {
ConfirmRecoveryKeyBanner(
@ -316,10 +303,10 @@ internal fun RoomListContentViewPreview(@PreviewParameter(RoomListContentStatePr
filterSelectionStates = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = true) }
),
eventSink = {},
onVerifyClicked = { },
onConfirmRecoveryKeyClicked = { },
onConfirmRecoveryKeyClicked = {},
onRoomClicked = {},
onRoomLongClicked = {},
onCreateRoomClicked = { },
onInvitesClicked = { })
onCreateRoomClicked = {},
onInvitesClicked = {}
)
}

View file

@ -239,52 +239,28 @@ class RoomListPresenterTests {
}
}
@Test
fun `present - handle RecoveryKeyConfirmation last session`() = runTest {
val scope = CoroutineScope(context = coroutineContext + SupervisorJob())
val roomListService = FakeRoomListService().apply {
postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
}
val presenter = createRoomListPresenter(
coroutineScope = scope,
client = FakeMatrixClient(
encryptionService = FakeEncryptionService().apply {
emitIsLastDevice(true)
},
roomListService = roomListService
),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val eventSink = consumeItemsUntilPredicate {
it.contentState is RoomListContentState.Rooms
}.last().eventSink
// For the last session, the state is not SessionVerification, but RecoveryKeyConfirmation
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.RecoveryKeyConfirmation)
eventSink(RoomListEvents.DismissRequestVerificationPrompt)
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
scope.cancel()
}
}
@Test
fun `present - handle DismissRequestVerificationPrompt`() = runTest {
val scope = CoroutineScope(context = coroutineContext + SupervisorJob())
val roomListService = FakeRoomListService().apply {
postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
}
val encryptionService = FakeEncryptionService().apply {
emitRecoveryState(RecoveryState.INCOMPLETE)
}
val syncService = FakeSyncService(initialState = SyncState.Running)
val presenter = createRoomListPresenter(
client = FakeMatrixClient(roomListService = roomListService),
client = FakeMatrixClient(roomListService = roomListService, encryptionService = encryptionService, syncService = syncService),
coroutineScope = scope,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val eventSink = consumeItemsUntilPredicate {
val eventWithContentAsRooms = consumeItemsUntilPredicate {
it.contentState is RoomListContentState.Rooms
}.last().eventSink
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.SessionVerification)
}.last()
val eventSink = eventWithContentAsRooms.eventSink
assertThat(eventWithContentAsRooms.contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.RecoveryKeyConfirmation)
eventSink(RoomListEvents.DismissRequestVerificationPrompt)
assertThat(awaitItem().contentAsRooms().securityBannerState).isEqualTo(SecurityBannerState.None)
scope.cancel()
@ -342,10 +318,10 @@ class RoomListPresenterTests {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
consumeItemsUntilPredicate {
val firstItem = consumeItemsUntilPredicate {
it.contentState is RoomListContentState.Rooms
}
assertThat(awaitItem().contentAsRooms().invitesState).isEqualTo(InvitesState.NoInvites)
}.last()
assertThat(firstItem.contentAsRooms().invitesState).isEqualTo(InvitesState.NoInvites)
inviteStateFlow.value = InvitesState.SeenInvites
assertThat(awaitItem().contentAsRooms().invitesState).isEqualTo(InvitesState.SeenInvites)

View file

@ -43,35 +43,6 @@ import org.junit.runner.RunWith
class RoomListViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on close verification banner emits the expected Event`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
rule.setRoomListView(
state = aRoomListState(
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SessionVerification),
eventSink = eventsRecorder,
)
)
val close = rule.activity.getString(CommonStrings.action_close)
rule.onNodeWithContentDescription(close).performClick()
eventsRecorder.assertSingle(RoomListEvents.DismissRequestVerificationPrompt)
}
@Test
fun `clicking on continue verification banner invokes the expected callback`() {
val eventsRecorder = EventsRecorder<RoomListEvents>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setRoomListView(
state = aRoomListState(
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SessionVerification),
eventSink = eventsRecorder,
),
onVerifyClicked = callback,
)
rule.clickOn(CommonStrings.action_continue)
}
}
@Test
fun `clicking on close recovery key banner emits the expected Event`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
@ -185,7 +156,6 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomL
state: RoomListState,
onRoomClicked: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onSettingsClicked: () -> Unit = EnsureNeverCalled(),
onVerifyClicked: () -> Unit = EnsureNeverCalled(),
onConfirmRecoveryKeyClicked: () -> Unit = EnsureNeverCalled(),
onCreateRoomClicked: () -> Unit = EnsureNeverCalled(),
onInvitesClicked: () -> Unit = EnsureNeverCalled(),
@ -198,7 +168,6 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomL
state = state,
onRoomClicked = onRoomClicked,
onSettingsClicked = onSettingsClicked,
onVerifyClicked = onVerifyClicked,
onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked,
onCreateRoomClicked = onCreateRoomClicked,
onInvitesClicked = onInvitesClicked,