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

@ -40,6 +40,8 @@ dependencies {
implementation(projects.libraries.elementresources)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.dateformatter.api)
implementation(libs.accompanist.placeholder)
api(projects.features.logout.api)
ksp(libs.showkase.processor)

View file

@ -34,6 +34,8 @@ interface RoomListEntryPoint : FeatureEntryPoint {
fun onRoomClicked(roomId: RoomId)
fun onCreateRoomClicked()
fun onSettingsClicked()
fun onSessionVerificationClicked()
}
}

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,
)

View file

@ -0,0 +1,29 @@
/*
* 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.
*/
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.verifysession.api"
}
dependencies {
implementation(projects.libraries.architecture)
}

View file

@ -0,0 +1,21 @@
/*
* 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.verifysession.api
import io.element.android.libraries.architecture.SimpleFeatureEntryPoint
interface VerifySessionEntryPoint : SimpleFeatureEntryPoint

View file

@ -0,0 +1 @@
/build

View file

@ -0,0 +1,56 @@
/*
* 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.
*/
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.ksp)
alias(libs.plugins.anvil)
}
android {
// TODO change the namespace (and your classes package)
namespace = "io.element.android.features.verifysession.impl"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
anvil(projects.anvilcodegen)
implementation(projects.anvilannotations)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.elementresources)
implementation(projects.libraries.uiStrings)
implementation(libs.accompanist.flowlayout)
api(projects.features.verifysession.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
ksp(libs.showkase.processor)
}

View file

@ -0,0 +1,32 @@
/*
* 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.verifysession.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultVerifySessionEntryPoint @Inject constructor() : VerifySessionEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
return parentNode.createNode<VerifySelfSessionNode>(buildContext)
}
}

View file

@ -0,0 +1,45 @@
/*
* 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.verifysession.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 dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
class VerifySelfSessionNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: VerifySelfSessionPresenter,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
VerifySelfSessionView(
state = state,
modifier = modifier,
goBack = { navigateUp() }
)
}
}

View file

@ -0,0 +1,109 @@
/*
* 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.verifysession.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import javax.inject.Inject
import io.element.android.features.verifysession.impl.VerifySelfSessionStateMachine.Event as StateMachineEvent
import io.element.android.features.verifysession.impl.VerifySelfSessionStateMachine.State as StateMachineState
class VerifySelfSessionPresenter @Inject constructor(
private val sessionVerificationService: SessionVerificationService,
) : Presenter<VerifySelfSessionState> {
@Composable
override fun present(): VerifySelfSessionState {
LaunchedEffect(Unit) {
// Force reset, just in case the service was left in a broken state
sessionVerificationService.reset()
}
val coroutineScope = rememberCoroutineScope()
val stateMachine = remember { VerifySelfSessionStateMachine(coroutineScope, sessionVerificationService) }
// Create the new view state from the StateMachine state
val stateMachineCurrentState by stateMachine.state.collectAsState()
val verificationFlowState by remember {
derivedStateOf { stateMachineStateToViewState(stateMachineCurrentState) }
}
fun handleEvents(event: VerifySelfSessionViewEvents) {
when (event) {
VerifySelfSessionViewEvents.RequestVerification -> stateMachine.process(StateMachineEvent.RequestVerification)
VerifySelfSessionViewEvents.StartSasVerification -> stateMachine.process(StateMachineEvent.StartSasVerification)
VerifySelfSessionViewEvents.Restart -> stateMachine.process(StateMachineEvent.Restart)
VerifySelfSessionViewEvents.ConfirmVerification -> stateMachine.process(StateMachineEvent.AcceptChallenge)
VerifySelfSessionViewEvents.DeclineVerification -> stateMachine.process(StateMachineEvent.DeclineChallenge)
VerifySelfSessionViewEvents.CancelAndClose -> {
if (stateMachineCurrentState !in sequenceOf(
StateMachineState.Initial,
StateMachineState.Completed,
StateMachineState.Canceled
)
) {
stateMachine.process(StateMachineEvent.Cancel)
}
}
}
}
return VerifySelfSessionState(
verificationFlowStep = verificationFlowState,
eventSink = ::handleEvents,
)
}
private fun stateMachineStateToViewState(state: StateMachineState): VerifySelfSessionState.VerificationStep =
when (state) {
StateMachineState.Initial -> {
VerifySelfSessionState.VerificationStep.Initial
}
StateMachineState.RequestingVerification,
StateMachineState.StartingSasVerification,
StateMachineState.SasVerificationStarted,
StateMachineState.VerificationRequestAccepted,
StateMachineState.Canceling -> {
VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse
}
StateMachineState.Canceled -> {
VerifySelfSessionState.VerificationStep.Canceled
}
is StateMachineState.Verifying -> {
val async = when (state) {
is StateMachineState.Verifying.Replying -> Async.Loading()
else -> Async.Uninitialized
}
VerifySelfSessionState.VerificationStep.Verifying(state.emojis, async)
}
StateMachineState.Completed -> {
VerifySelfSessionState.VerificationStep.Completed
}
}
}

View file

@ -0,0 +1,38 @@
/*
* 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.verifysession.impl
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
@Immutable
data class VerifySelfSessionState(
val verificationFlowStep: VerificationStep,
val eventSink: (VerifySelfSessionViewEvents) -> Unit,
) {
@Stable
sealed interface VerificationStep {
object Initial : VerificationStep
object Canceled : VerificationStep
object AwaitingOtherDeviceResponse : VerificationStep
data class Verifying(val emojiList: List<VerificationEmoji>, val state: Async<Unit>) : VerificationStep
object Completed : VerificationStep
}
}

View file

@ -0,0 +1,171 @@
/*
* 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.
*/
@file:Suppress("WildcardImport")
package io.element.android.features.verifysession.impl
import io.element.android.libraries.core.statemachine.createStateMachine
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
class VerifySelfSessionStateMachine(
coroutineScope: CoroutineScope,
private val sessionVerificationService: SessionVerificationService,
) {
private val stateMachine = createStateMachine {
addInitialState(State.Initial) {
on<Event.RequestVerification>(State.RequestingVerification)
on<Event.StartSasVerification>(State.StartingSasVerification)
}
addState<State.RequestingVerification> {
onEnter { sessionVerificationService.requestVerification() }
on<Event.DidAcceptVerificationRequest>(State.VerificationRequestAccepted)
on<Event.DidFail>(State.Initial)
}
addState<State.StartingSasVerification> {
onEnter { sessionVerificationService.startVerification() }
}
addState<State.VerificationRequestAccepted> {
on<Event.StartSasVerification>(State.StartingSasVerification)
}
addState<State.Canceled> {
on<Event.Restart>(State.RequestingVerification)
}
addState<State.SasVerificationStarted> {
on<Event.DidReceiveChallenge> { event, _ -> State.Verifying.ChallengeReceived(event.emojis) }
}
addState<State.Verifying.ChallengeReceived> {
on<Event.AcceptChallenge> { _, prevState -> State.Verifying.Replying(prevState.emojis, true) }
on<Event.DeclineChallenge> { _, prevState -> State.Verifying.Replying(prevState.emojis, false) }
}
addState<State.Verifying.Replying> {
onEnter { state ->
if (state.accept) {
sessionVerificationService.approveVerification()
} else {
sessionVerificationService.declineVerification()
}
}
on<Event.DidAcceptChallenge>(State.Completed)
}
addState<State.Canceling> {
onEnter { sessionVerificationService.cancelVerification() }
}
on<Event.DidStartSasVerification>(State.SasVerificationStarted)
on<Event.Cancel>(State.Canceling)
on<Event.DidCancel>(State.Canceled)
on<Event.DidFail>(State.Canceled)
}
init {
// Observe the verification service state, translate it to state machine input events
sessionVerificationService.verificationFlowState.onEach { verificationAttemptState ->
when (verificationAttemptState) {
VerificationFlowState.AcceptedVerificationRequest -> {
stateMachine.process(Event.DidAcceptVerificationRequest)
}
VerificationFlowState.StartedSasVerification -> {
stateMachine.process(Event.DidStartSasVerification)
}
is VerificationFlowState.ReceivedVerificationData -> {
// For some reason we receive this state twice, we need to discard the 2nd one
if (stateMachine.currentState == State.SasVerificationStarted) {
stateMachine.process(Event.DidReceiveChallenge(verificationAttemptState.emoji))
}
}
VerificationFlowState.Finished -> {
stateMachine.process(Event.DidAcceptChallenge)
}
VerificationFlowState.Canceled -> {
stateMachine.process(Event.DidCancel)
}
VerificationFlowState.Failed -> {
stateMachine.process(Event.DidFail)
}
else -> Unit
}
}.launchIn(coroutineScope)
}
val state: StateFlow<State> = stateMachine.stateFlow
fun process(event: Event) = stateMachine.process(event)
sealed interface State {
/** The initial state, before verification started. */
object Initial : State
/** Waiting for verification acceptance. */
object RequestingVerification : State
/** Verification request accepted. Waiting for start. */
object VerificationRequestAccepted : State
/** Waiting for SaS verification start. */
object StartingSasVerification : State
/** A SaS verification flow has been started. */
object SasVerificationStarted : State
sealed class Verifying(open val emojis: List<VerificationEmoji>) : State {
/** Verification accepted and emojis received. */
data class ChallengeReceived(override val emojis: List<VerificationEmoji>) : Verifying(emojis)
/** Replying to a verification challenge. */
data class Replying(override val emojis: List<VerificationEmoji>, val accept: Boolean) : Verifying(emojis)
}
/** The verification is being canceled. */
object Canceling : State
/** The verification has been canceled, remotely or locally. */
object Canceled : State
/** Verification successful. */
object Completed : State
}
sealed interface Event {
/** Request verification. */
object RequestVerification : Event
/** The current verification request has been accepted. */
object DidAcceptVerificationRequest : Event
/** Start a SaS verification flow. */
object StartSasVerification : Event
/** Started a SaS verification flow. */
object DidStartSasVerification : Event
/** Has received emojis. */
data class DidReceiveChallenge(val emojis: List<VerificationEmoji>) : Event
/** Emojis match. */
object AcceptChallenge : Event
/** Emojis do not match. */
object DeclineChallenge : Event
/** Remote accepted challenge. */
object DidAcceptChallenge : Event
/** Request cancellation. */
object Cancel : Event
/** Verification cancelled. */
object DidCancel : Event
/** Request failed. */
object DidFail : Event
/** Restart the verification flow. */
object Restart : Event
}
}

View file

@ -0,0 +1,48 @@
/*
* 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.verifysession.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
open class VerifySelfSessionStateProvider : PreviewParameterProvider<VerifySelfSessionState> {
override val values: Sequence<VerifySelfSessionState>
get() = sequenceOf(
aTemplateState(),
aTemplateState().copy(verificationFlowStep = VerifySelfSessionState.VerificationStep.AwaitingOtherDeviceResponse),
aTemplateState().copy(verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(aVerificationEmojiList(), Async.Uninitialized)),
aTemplateState().copy(verificationFlowStep = VerifySelfSessionState.VerificationStep.Verifying(aVerificationEmojiList(), Async.Loading())),
aTemplateState().copy(verificationFlowStep = VerifySelfSessionState.VerificationStep.Canceled),
// Add other state here
)
}
fun aTemplateState() = VerifySelfSessionState(
verificationFlowStep = VerifySelfSessionState.VerificationStep.Initial,
eventSink = {},
)
fun aVerificationEmojiList() = listOf(
VerificationEmoji("🍕", "Pizza"),
VerificationEmoji("🚀", "Rocket"),
VerificationEmoji("🚀", "Rocket"),
VerificationEmoji("🗺️", "Map"),
VerificationEmoji("🎳", "Bowling"),
VerificationEmoji("🎳", "Bowling"),
VerificationEmoji("📌", "Pin"),
)

View file

@ -0,0 +1,289 @@
/*
* 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.verifysession.impl
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.dp
import androidx.compose.ui.unit.sp
import com.google.accompanist.flowlayout.FlowRow
import com.google.accompanist.flowlayout.MainAxisAlignment
import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep as FlowStep
import io.element.android.libraries.architecture.Async
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.LocalColors
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonCircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.R as StringR
@Composable
fun VerifySelfSessionView(
state: VerifySelfSessionState,
modifier: Modifier = Modifier,
goBack: () -> Unit,
) {
fun goBackAndCancelIfNeeded() {
state.eventSink(VerifySelfSessionViewEvents.CancelAndClose)
goBack()
}
if (state.verificationFlowStep is FlowStep.Completed) {
goBack()
}
BackHandler {
goBackAndCancelIfNeeded()
}
val verificationFlowStep = state.verificationFlowStep
val buttonsVisible by remember(verificationFlowStep) {
derivedStateOf { verificationFlowStep != FlowStep.AwaitingOtherDeviceResponse && verificationFlowStep != FlowStep.Completed }
}
Surface {
Column(modifier = modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
.weight(1f)
.verticalScroll(rememberScrollState())
) {
HeaderContent(verificationFlowStep = verificationFlowStep)
Content(flowState = verificationFlowStep)
}
if (buttonsVisible) {
BottomMenu(screenState = state, goBack = ::goBackAndCancelIfNeeded)
}
}
}
}
@Composable
internal fun HeaderContent(verificationFlowStep: FlowStep, modifier: Modifier = Modifier) {
val iconResourceId = when (verificationFlowStep) {
FlowStep.Initial -> R.drawable.ic_verification_devices
FlowStep.Canceled -> R.drawable.ic_verification_warning
FlowStep.AwaitingOtherDeviceResponse -> R.drawable.ic_verification_waiting
is FlowStep.Verifying, FlowStep.Completed -> R.drawable.ic_verification_emoji
}
val titleTextId = when (verificationFlowStep) {
FlowStep.Initial -> StringR.string.verification_title_initial
FlowStep.Canceled -> StringR.string.verification_title_canceled
FlowStep.AwaitingOtherDeviceResponse -> StringR.string.verification_title_waiting
is FlowStep.Verifying, FlowStep.Completed -> StringR.string.verification_title_verifying
}
val subtitleTextId = when (verificationFlowStep) {
FlowStep.Initial -> StringR.string.verification_subtitle_initial
FlowStep.Canceled -> StringR.string.verification_subtitle_canceled
FlowStep.AwaitingOtherDeviceResponse -> StringR.string.verification_subtitle_waiting
is FlowStep.Verifying, FlowStep.Completed -> StringR.string.verification_subtitle_verifying
}
Column(modifier) {
Spacer(Modifier.height(68.dp))
Box(
modifier = Modifier
.size(width = 70.dp, height = 70.dp)
.align(Alignment.CenterHorizontally)
.background(
color = LocalColors.current.quinary,
shape = RoundedCornerShape(14.dp)
)
) {
Spacer(modifier = Modifier.height(68.dp))
Icon(
modifier = Modifier
.align(Alignment.Center)
.size(width = 48.dp, height = 48.dp),
tint = MaterialTheme.colorScheme.secondary,
resourceId = iconResourceId,
contentDescription = "",
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(id = titleTextId),
modifier = Modifier
.fillMaxWidth()
.align(Alignment.CenterHorizontally),
textAlign = TextAlign.Center,
style = ElementTextStyles.Bold.title2,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(id = subtitleTextId),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = ElementTextStyles.Regular.subheadline,
color = MaterialTheme.colorScheme.secondary,
)
}
}
@Composable
internal fun Content(flowState: FlowStep, modifier: Modifier = Modifier) {
Column (modifier){
Spacer(Modifier.height(56.dp))
when (flowState) {
FlowStep.Initial, FlowStep.Canceled, FlowStep.Completed -> Unit
FlowStep.AwaitingOtherDeviceResponse -> ContentWaiting()
is FlowStep.Verifying -> ContentVerifying(flowState)
}
Spacer(Modifier.height(56.dp))
}
}
@Composable
internal fun ContentWaiting(modifier: Modifier = Modifier) {
Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
CircularProgressIndicator()
}
}
@Composable
internal fun ContentVerifying(verificationFlowStep: FlowStep.Verifying, modifier: Modifier = Modifier) {
FlowRow(
modifier = modifier.fillMaxWidth(),
mainAxisAlignment = MainAxisAlignment.Center,
mainAxisSpacing = 32.dp,
crossAxisSpacing = 40.dp
) {
for (entry in verificationFlowStep.emojiList) {
Column(
modifier = Modifier.defaultMinSize(minWidth = 56.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(entry.code, fontSize = 34.sp)
Spacer(modifier = Modifier.height(16.dp))
Text(entry.name, style = ElementTextStyles.Regular.body)
}
}
}
}
@Composable
internal fun BottomMenu(screenState: VerifySelfSessionState, goBack: () -> Unit) {
val verificationViewState = screenState.verificationFlowStep
val eventSink = screenState.eventSink
val isVerifying = (verificationViewState as? FlowStep.Verifying)?.state is Async.Loading<Unit>
val positiveButtonTitle = when (verificationViewState) {
FlowStep.Initial -> StringR.string.verification_positive_button_initial
FlowStep.Canceled -> StringR.string.verification_positive_button_canceled
is FlowStep.Verifying -> {
if (isVerifying) {
StringR.string.verification_positive_button_verifying_ongoing
} else {
StringR.string.verification_positive_button_verifying_start
}
}
else -> null
}
val negativeButtonTitle = when (verificationViewState) {
FlowStep.Initial -> StringR.string.verification_negative_button_initial
FlowStep.Canceled -> StringR.string.verification_negative_button_canceled
is FlowStep.Verifying -> StringR.string.verification_negative_button_verifying
else -> null
}
val negativeButtonEnabled = !isVerifying
val positiveButtonEvent = when (verificationViewState) {
FlowStep.Initial -> VerifySelfSessionViewEvents.RequestVerification
is FlowStep.Verifying -> if (!isVerifying) VerifySelfSessionViewEvents.ConfirmVerification else null
FlowStep.Canceled -> VerifySelfSessionViewEvents.Restart
else -> null
}
val negativeButtonCallback: () -> Unit = when (verificationViewState) {
is FlowStep.Verifying -> { { eventSink(VerifySelfSessionViewEvents.DeclineVerification) } }
else -> goBack
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(
modifier = Modifier.fillMaxWidth(),
onClick = { positiveButtonEvent?.let { eventSink(it) } }
) {
if (isVerifying) {
ButtonCircularProgressIndicator()
Spacer(Modifier.width(10.dp))
}
positiveButtonTitle?.let { Text(stringResource(it)) }
}
Spacer(modifier = Modifier.height(16.dp))
TextButton(
modifier = Modifier.fillMaxWidth(),
onClick = negativeButtonCallback,
enabled = negativeButtonEnabled,
) {
negativeButtonTitle?.let { Text(stringResource(it)) }
}
Spacer(Modifier.height(40.dp))
}
}
@Preview
@Composable
fun TemplateViewLightPreview(@PreviewParameter(VerifySelfSessionStateProvider::class) state: VerifySelfSessionState) =
ElementPreviewLight { ContentToPreview(state) }
@Preview
@Composable
fun TemplateViewDarkPreview(@PreviewParameter(VerifySelfSessionStateProvider::class) state: VerifySelfSessionState) =
ElementPreviewDark { ContentToPreview(state) }
@Composable
private fun ContentToPreview(state: VerifySelfSessionState) {
VerifySelfSessionView(
state = state,
goBack = {},
)
}

View file

@ -0,0 +1,26 @@
/*
* 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.verifysession.impl
sealed interface VerifySelfSessionViewEvents {
object RequestVerification: VerifySelfSessionViewEvents
object StartSasVerification: VerifySelfSessionViewEvents
object Restart: VerifySelfSessionViewEvents
object ConfirmVerification: VerifySelfSessionViewEvents
object DeclineVerification: VerifySelfSessionViewEvents
object CancelAndClose: VerifySelfSessionViewEvents
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="@android:color/white"
android:pathData="M8.3,35.5V11Q8.3,9.8 9.2,8.9Q10.1,8 11.3,8H40.8Q41.45,8 41.875,8.425Q42.3,8.85 42.3,9.5Q42.3,10.15 41.875,10.575Q41.45,11 40.8,11H11.3Q11.3,11 11.3,11Q11.3,11 11.3,11V35.5H20.75Q21.7,35.5 22.35,36.15Q23,36.8 23,37.75Q23,38.7 22.35,39.35Q21.7,40 20.75,40H6.25Q5.3,40 4.65,39.35Q4,38.7 4,37.75Q4,36.8 4.65,36.15Q5.3,35.5 6.25,35.5ZM27.95,40Q27.15,40 26.575,39.4Q26,38.8 26,37.8V15.95Q26,15.15 26.575,14.575Q27.15,14 27.95,14H41.55Q42.55,14 43.275,14.575Q44,15.15 44,15.95V37.8Q44,38.8 43.275,39.4Q42.55,40 41.55,40ZM29,35.5H41V17H29Z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="@android:color/white"
android:pathData="M31.3,21.35Q32.45,21.35 33.225,20.575Q34,19.8 34,18.65Q34,17.5 33.225,16.725Q32.45,15.95 31.3,15.95Q30.15,15.95 29.375,16.725Q28.6,17.5 28.6,18.65Q28.6,19.8 29.375,20.575Q30.15,21.35 31.3,21.35ZM16.7,21.35Q17.85,21.35 18.625,20.575Q19.4,19.8 19.4,18.65Q19.4,17.5 18.625,16.725Q17.85,15.95 16.7,15.95Q15.55,15.95 14.775,16.725Q14,17.5 14,18.65Q14,19.8 14.775,20.575Q15.55,21.35 16.7,21.35ZM24,34.95Q26.85,34.95 29.375,33.6Q31.9,32.25 33.35,29.85Q33.75,29.25 33.425,28.8Q33.1,28.35 32.4,28.35H15.6Q14.9,28.35 14.6,28.8Q14.3,29.25 14.7,29.85Q16.15,32.25 18.65,33.6Q21.15,34.95 24,34.95ZM24,44Q19.9,44 16.25,42.425Q12.6,40.85 9.875,38.125Q7.15,35.4 5.575,31.75Q4,28.1 4,23.95Q4,19.85 5.575,16.2Q7.15,12.55 9.875,9.85Q12.6,7.15 16.25,5.575Q19.9,4 24.05,4Q28.15,4 31.8,5.575Q35.45,7.15 38.15,9.85Q40.85,12.55 42.425,16.2Q44,19.85 44,24Q44,28.1 42.425,31.75Q40.85,35.4 38.15,38.125Q35.45,40.85 31.8,42.425Q28.15,44 24,44ZM24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24ZM24,41Q31.1,41 36.05,36.025Q41,31.05 41,24Q41,16.9 36.05,11.95Q31.1,7 24,7Q16.95,7 11.975,11.95Q7,16.9 7,24Q7,31.05 11.975,36.025Q16.95,41 24,41Z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="@android:color/white"
android:pathData="M15.8,41H32.2V34.65Q32.2,31.15 29.825,28.625Q27.45,26.1 24,26.1Q20.55,26.1 18.175,28.625Q15.8,31.15 15.8,34.65ZM38.5,44H9.5Q8.85,44 8.425,43.575Q8,43.15 8,42.5Q8,41.85 8.425,41.425Q8.85,41 9.5,41H12.8V34.65Q12.8,31.15 14.625,28.225Q16.45,25.3 19.7,24Q16.45,22.7 14.625,19.75Q12.8,16.8 12.8,13.3V7H9.5Q8.85,7 8.425,6.575Q8,6.15 8,5.5Q8,4.85 8.425,4.425Q8.85,4 9.5,4H38.5Q39.15,4 39.575,4.425Q40,4.85 40,5.5Q40,6.15 39.575,6.575Q39.15,7 38.5,7H35.2V13.3Q35.2,16.8 33.35,19.75Q31.5,22.7 28.3,24Q31.55,25.3 33.375,28.225Q35.2,31.15 35.2,34.65V41H38.5Q39.15,41 39.575,41.425Q40,41.85 40,42.5Q40,43.15 39.575,43.575Q39.15,44 38.5,44Z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="@android:color/white"
android:pathData="M24,24.6Q24.65,24.6 25.075,24.175Q25.5,23.75 25.5,23.1V15.75Q25.5,15.1 25.075,14.675Q24.65,14.25 24,14.25Q23.35,14.25 22.925,14.675Q22.5,15.1 22.5,15.75V23.1Q22.5,23.75 22.925,24.175Q23.35,24.6 24,24.6ZM24,31.3Q24.7,31.3 25.2,30.8Q25.7,30.3 25.7,29.6Q25.7,28.9 25.2,28.4Q24.7,27.9 24,27.9Q23.3,27.9 22.8,28.4Q22.3,28.9 22.3,29.6Q22.3,30.3 22.8,30.8Q23.3,31.3 24,31.3ZM24,43.85Q23.8,43.85 23.625,43.825Q23.45,43.8 23.3,43.75Q16.6,41.75 12.3,35.525Q8,29.3 8,21.85V12.05Q8,11.1 8.55,10.325Q9.1,9.55 9.95,9.2L22.95,4.35Q23.5,4.15 24,4.15Q24.5,4.15 25.05,4.35L38.05,9.2Q38.9,9.55 39.45,10.325Q40,11.1 40,12.05V21.85Q40,29.3 35.7,35.525Q31.4,41.75 24.7,43.75Q24.7,43.75 24,43.85ZM24,40.85Q29.75,38.95 33.375,33.675Q37,28.4 37,21.85V12.05Q37,12.05 37,12.05Q37,12.05 37,12.05L24,7.15Q24,7.15 24,7.15Q24,7.15 24,7.15L11,12.05Q11,12.05 11,12.05Q11,12.05 11,12.05V21.85Q11,28.4 14.625,33.675Q18.25,38.95 24,40.85ZM24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Q24,24 24,24Z"/>
</vector>

View file

@ -0,0 +1,241 @@
/*
* 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.verifysession.impl
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.Event
import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.verifysession.impl.VerifySelfSessionState.VerificationStep as VerificationStep
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
import io.element.android.libraries.matrix.api.verification.VerificationEmoji
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import kotlinx.coroutines.test.runTest
import org.junit.Test
class VerifySelfSessionPresenterTests {
@Test
fun `present - Initial state is received`() = runTest {
val service = FakeSessionVerificationService()
val presenter = VerifySelfSessionPresenter(service)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Initial)
}
}
@Test
fun `present - Handles requestVerification`() = runTest {
val service = FakeSessionVerificationService()
val presenter = VerifySelfSessionPresenter(service)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial)
val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.RequestVerification)
// Await for other device response:
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
// Finally, ChallengeReceived:
val verifyingState = awaitItem()
assertThat(verifyingState.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java)
}
}
@Test
fun `present - Handles startSasVerification`() = runTest {
val service = FakeSessionVerificationService()
val presenter = VerifySelfSessionPresenter(service)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial)
val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.StartSasVerification)
// Await for other device response:
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
// ChallengeReceived:
val verifyingState = awaitItem()
assertThat(verifyingState.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java)
}
}
@Test
fun `present - Cancelation on initial state does nothing`() = runTest {
val service = FakeSessionVerificationService()
val presenter = VerifySelfSessionPresenter(service)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial)
val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.CancelAndClose)
expectNoEvents()
}
}
@Test
fun `present - A fail in the flow cancels it`() = runTest {
val service = FakeSessionVerificationService()
val presenter = VerifySelfSessionPresenter(service)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial)
val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.RequestVerification)
val verifyingState = awaitChallengeReceivedState()
assertThat(verifyingState.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java)
service.shouldFail = true
eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
val remainingEvents = cancelAndConsumeRemainingEvents().mapNotNull { (it as? Event.Item<VerifySelfSessionState>)?.value }
assertThat(remainingEvents.last().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
}
}
@Test
fun `present - Canceling the flow once it's verifying cancels it`() = runTest {
val service = FakeSessionVerificationService()
val presenter = VerifySelfSessionPresenter(service)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial)
val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.RequestVerification)
val verifyingState = awaitChallengeReceivedState()
assertThat(verifyingState.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java)
eventSink(VerifySelfSessionViewEvents.CancelAndClose)
val remainingEvents = cancelAndConsumeRemainingEvents().mapNotNull { (it as? Event.Item<VerifySelfSessionState>)?.value }
assertThat(remainingEvents.last().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
}
}
@Test
fun `present - When verifying, if we receive another challenge we ignore it`() = runTest {
val service = FakeSessionVerificationService()
val presenter = VerifySelfSessionPresenter(service)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial)
val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.RequestVerification)
val verifyingState = awaitChallengeReceivedState()
assertThat(verifyingState.verificationFlowStep).isInstanceOf(VerificationStep.Verifying::class.java)
service.givenVerificationFlowState(VerificationFlowState.ReceivedVerificationData(emptyList()))
ensureAllEventsConsumed()
}
}
@Test
fun `present - Restart after cancelation returns to requesting verification`() = runTest {
val service = FakeSessionVerificationService()
val presenter = VerifySelfSessionPresenter(service)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial)
val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.RequestVerification)
assertThat(awaitChallengeReceivedState().verificationFlowStep).isEqualTo(VerificationStep.Verifying(emptyList(), Async.Uninitialized))
service.givenVerificationFlowState(VerificationFlowState.Canceled)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
eventSink(VerifySelfSessionViewEvents.Restart)
// Went back to requesting verification
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.AwaitingOtherDeviceResponse)
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - When verification is approved, the flow completes if there is no error`() = runTest {
val emojis = listOf<VerificationEmoji>(
VerificationEmoji("😄", "Smile")
)
val service = FakeSessionVerificationService().apply {
givenEmojiList(emojis)
}
val presenter = VerifySelfSessionPresenter(service)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial)
val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.RequestVerification)
assertThat(awaitChallengeReceivedState().verificationFlowStep).isEqualTo(VerificationStep.Verifying(emojis, Async.Uninitialized))
eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Verifying(emojis, Async.Loading()))
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Completed)
}
}
@Test
fun `present - When verification is declined, the flow is canceled`() = runTest {
val service = FakeSessionVerificationService()
val presenter = VerifySelfSessionPresenter(service)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.verificationFlowStep).isEqualTo(VerificationStep.Initial)
val eventSink = initialState.eventSink
eventSink(VerifySelfSessionViewEvents.RequestVerification)
assertThat(awaitChallengeReceivedState().verificationFlowStep).isEqualTo(VerificationStep.Verifying(emptyList(), Async.Uninitialized))
eventSink(VerifySelfSessionViewEvents.DeclineVerification)
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Verifying(emptyList(), Async.Loading()))
assertThat(awaitItem().verificationFlowStep).isEqualTo(VerificationStep.Canceled)
}
}
private suspend fun ReceiveTurbine<VerifySelfSessionState>.awaitChallengeReceivedState(): VerifySelfSessionState {
// Skip 'waiting for response' state
skipItems(1)
// Received challenge
return awaitItem()
}
}