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

@ -45,6 +45,7 @@ dependencies {
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrixui)
implementation(projects.features.verifysession.api)
implementation(projects.tests.uitests)
implementation(libs.coil)

View file

@ -38,6 +38,7 @@ import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.features.roomlist.api.RoomListEntryPoint
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
@ -61,6 +62,7 @@ class LoggedInFlowNode @AssistedInject constructor(
private val preferencesEntryPoint: PreferencesEntryPoint,
private val createRoomEntryPoint: CreateRoomEntryPoint,
private val appNavigationStateService: AppNavigationStateService,
private val verifySessionEntryPoint: VerifySessionEntryPoint,
) : BackstackNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.RoomList,
@ -120,6 +122,9 @@ class LoggedInFlowNode @AssistedInject constructor(
@Parcelize
object CreateRoom : NavTarget
@Parcelize
object VerifySession : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -137,6 +142,10 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun onCreateRoomClicked() {
backstack.push(NavTarget.CreateRoom)
}
override fun onSessionVerificationClicked() {
backstack.push(NavTarget.VerifySession)
}
}
roomListEntryPoint
.nodeBuilder(this, buildContext)
@ -171,6 +180,9 @@ class LoggedInFlowNode @AssistedInject constructor(
NavTarget.CreateRoom -> {
createRoomEntryPoint.createNode(this, buildContext)
}
NavTarget.VerifySession -> {
verifySessionEntryPoint.createNode(this, buildContext)
}
}
}

1
changelog.d/89.feature Normal file
View file

@ -0,0 +1 @@
Add self session verification flow.

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()
}
}

View file

@ -29,4 +29,7 @@ java {
dependencies {
implementation(libs.coroutines.core)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
}

View file

@ -0,0 +1,192 @@
/*
* 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.libraries.core.statemachine
import io.element.android.libraries.core.bool.orFalse
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
fun <Event : Any, State : Any> createStateMachine(
config: StateMachineBuilder<Event, State>.() -> Unit
): StateMachine<Event, State> {
val builder = StateMachineBuilder<Event, State>()
config(builder)
return builder.build()
}
class StateMachine<Event : Any, State : Any>(
val initialState: State,
private val stateConfigs: Map<Class<*>, StateConfig<*>>,
private val routes: List<StateMachineRoute<*, *, *>>,
) {
private val _stateFlow = MutableStateFlow(initialState)
val stateFlow = _stateFlow.asStateFlow()
val currentState: State get() = stateFlow.value
var transitionHandler: ((State, Event, State) -> Unit)? = null
init {
@Suppress("UNCHECKED_CAST")
val initialStateConfig = stateConfigs[initialState::class.java] as StateConfig<State>
initialStateConfig.onEnter?.invoke(initialState)
}
@Suppress("UNCHECKED_CAST")
fun <E : Event> process(event: E) {
val route = findMatchingRoute(event) ?: error("No route found for state $currentState on event $event")
val lastStateConfig: StateConfig<State>? = stateConfigs[currentState::class.java] as? StateConfig<State>
lastStateConfig?.onExit?.invoke(currentState)
val nextState = route.toState(event, currentState)
transitionHandler?.invoke(currentState, event, nextState)
_stateFlow.value = nextState
val currentStateConfig = stateConfigs[nextState::class.java] as? StateConfig<State>
currentStateConfig?.onEnter?.invoke(nextState)
}
private fun <E : Event> findMatchingRoute(event: E): StateMachineRoute<E, State, State>? {
val routesForEvent = routes.filter { it.eventType.isInstance(event) }
return (routesForEvent.firstOrNull { it.fromState?.isInstance(currentState).orFalse() }
?: routesForEvent.firstOrNull { it.fromState == null }) as? StateMachineRoute<E, State, State>
}
fun restart() {
_stateFlow.value = initialState
}
}
class StateMachineBuilder<Event : Any, State : Any>(
val routes: MutableList<StateMachineRoute<out Event, out State, out State>> = mutableListOf(),
) {
lateinit var initialState: State
var stateConfigs = mutableMapOf<Class<out State>, StateConfig<out State>>()
inline fun <reified S : State> addState(block: StateRegistrationBuilder<Event, State, S>.() -> Unit = {}) {
val config = StateConfig(S::class.java)
val registrationBuilder = StateRegistrationBuilder<Event, State, S>(config)
block(registrationBuilder)
verifyRoutesAreUnique(S::class.java, routes, registrationBuilder.routes)
if (stateConfigs.contains(S::class.java)) {
error("Duplicate registration for state ${S::class.java.name}")
}
stateConfigs[S::class.java] = config
routes.addAll(registrationBuilder.routes)
}
inline fun <reified S : State> addInitialState(state: S, config: StateRegistrationBuilder<Event, State, S>.() -> Unit = {}) {
initialState = state
addState(block = config)
}
inline fun <reified E : Event, reified S : State> on(noinline configuration: (E, State) -> S) {
val builder = RouteBuilder<E, State, S>(E::class.java, null)
builder.toState = configuration
val newRoute = builder.build()
verifyRoutesAreUnique(S::class.java, routes, listOf(newRoute))
routes.add(newRoute)
}
inline fun <reified E : Event> on(newState: State) {
val builder = RouteBuilder<E, State, State>(E::class.java, null)
builder.toState = { _, _ -> newState }
val newRoute = builder.build()
verifyRoutesAreUnique(null, routes, listOf(newRoute))
routes.add(newRoute)
}
fun build(): StateMachine<Event, State> {
if (::initialState.isInitialized) {
return StateMachine(initialState, stateConfigs.toMap(), routes)
} else {
error("The state machine has no initial state")
}
}
companion object {
fun verifyRoutesAreUnique(
state: Class<*>?,
oldRoutes: List<StateMachineRoute<*, *, *>>,
newRoutes: List<StateMachineRoute<*, *, *>>,
) {
val oldEvents = oldRoutes.filter { it.fromState == state }.map { it.eventType }
val newEvents = newRoutes.filter { it.fromState == state }.map { it.eventType }
val intersection = oldEvents.intersect(newEvents)
if (intersection.isNotEmpty()) {
val duplicates = intersection.joinToString(", ") { it.name }
error("Duplicate registration in state ${state?.name} for events: $duplicates")
}
}
}
}
class StateRegistrationBuilder<Event : Any, BaseState : Any, State : BaseState>(
val fromState: StateConfig<State>,
val routes: MutableList<StateMachineRoute<out Event, out State, out BaseState>> = mutableListOf(),
) {
fun onEnter(enter: (State) -> Unit) {
fromState.onEnter = enter
}
fun onExit(exit: (State) -> Unit) {
fromState.onExit = exit
}
inline fun <reified E : Event> on(noinline configuration: (E, State) -> BaseState) {
val builder = RouteBuilder<E, State, BaseState>(E::class.java, fromState.state)
builder.toState = configuration
val newRoute = builder.build()
StateMachineBuilder.verifyRoutesAreUnique(fromState.state, routes, listOf(newRoute))
routes.add(newRoute)
}
inline fun <reified E : Event> on(newState: BaseState) {
val builder = RouteBuilder<E, State, BaseState>(E::class.java, fromState.state)
builder.toState = { _, _ -> newState }
val newRoute = builder.build()
StateMachineBuilder.verifyRoutesAreUnique(fromState.state, routes, listOf(newRoute))
routes.add(newRoute)
}
}
class RouteBuilder<Event : Any, FromState : Any, ToState : Any>(
val eventType: Class<out Event>,
val fromState: Class<out FromState>?,
) {
lateinit var toState: (Event, FromState) -> ToState
fun build() = StateMachineRoute(eventType, fromState, toState)
}
data class StateMachineRoute<Event : Any, FromState : Any, ToState : Any>(
val eventType: Class<out Event>,
val fromState: Class<out FromState>?,
val toState: (Event, FromState) -> ToState,
)
data class StateConfig<State : Any>(
val state: Class<State>,
var onEnter: ((State) -> Unit)? = null,
var onExit: ((State) -> Unit)? = null,
)

View file

@ -0,0 +1,202 @@
/*
* 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.libraries.core.statemachine
import com.google.common.truth.Truth.assertThat
import org.junit.Assert.fail
import org.junit.Test
class StateMachineTests {
sealed interface Events {
data class GoToSecond(val string: String) : Events
object GoToThird : Events
object GoToFourth : Events
object Cancel : Events
}
sealed interface States {
object First : States
data class Second(val string: String) : States
object Third : States
object Fourth : States
object Canceled : States
}
private var enteredSecondState = false
private var exitedFirstState = false
private var transitionHandlerParams: Triple<States, Events, States>? = null
private fun aStateMachine() = createStateMachine<Events, States> {
addInitialState(States.First) {
onExit { exitedFirstState = true }
on<Events.GoToSecond> { first, _ ->
States.Second(first.string)
}
}
addState<States.Second> {
onEnter { enteredSecondState = true }
on<Events.GoToThird>(States.Third)
}
addState<States.Fourth>()
on<Events.GoToFourth, States.Fourth> { _, _ -> States.Fourth }
on<Events.Cancel>(States.Canceled)
}
@Test
fun `process - moves to next state given an event if the route exists`() = aStateMachine().run {
process(Events.GoToSecond("Hello"))
assertThat(currentState).isEqualTo(States.Second("Hello"))
process(Events.GoToThird)
assertThat(currentState).isEqualTo(States.Third)
process(Events.GoToFourth)
assertThat(currentState).isEqualTo(States.Fourth)
}
@Test
fun `process - throws exception if there is no route for an event in a state`() = aStateMachine().run {
runCatching {
process(Events.GoToThird)
}.onSuccess {
fail("It should have thrown an error")
}.onFailure {
assertThat(it.message).startsWith("No route found for state")
}
Unit
}
@Test
fun `process - calls onEnter and onExit callbacks when moving through states`() = aStateMachine().run {
process(Events.GoToSecond("Hello"))
assertThat(currentState).isEqualTo(States.Second("Hello"))
assertThat(exitedFirstState).isTrue()
assertThat(enteredSecondState).isTrue()
}
@Test
fun `process - if an Event route is registered inside a state and outside it, the internal registration takes precedence`() {
val customStateMachine = createStateMachine {
addInitialState(States.First) {
on<Events.Cancel>(States.Canceled)
}
on<Events.Cancel>(States.Fourth)
}
customStateMachine.process(Events.Cancel)
assertThat(customStateMachine.currentState).isEqualTo(States.Canceled)
}
@Test
fun `transitionHandler - is called when moving from a state to another`() = aStateMachine().run {
transitionHandler = { from, event, to ->
transitionHandlerParams = Triple(from, event, to)
}
process(Events.GoToSecond("Hello"))
assertThat(transitionHandlerParams).isEqualTo(
Triple(
States.First,
Events.GoToSecond("Hello"),
States.Second("Hello"),
)
)
}
@Test
fun `restart - sets the state machine to its initial state`() {
val customStateMachine = createStateMachine {
addInitialState(States.First)
on<Events.GoToFourth>(States.Fourth)
}
customStateMachine.process(Events.GoToFourth)
assertThat(customStateMachine.currentState).isEqualTo(States.Fourth)
customStateMachine.restart()
assertThat(customStateMachine.currentState).isEqualTo(customStateMachine.initialState)
}
@Test
fun `init - the state machine must have registered a initial state`() {
runCatching {
createStateMachine<Events, States> {
addState<States.Second>()
on<Events.Cancel>(States.Canceled)
}
}.onSuccess {
fail("It should have thrown an error")
}.onFailure { error ->
assertThat(error.message).isEqualTo("The state machine has no initial state")
}
Unit
}
@Test
fun `init - the state machine having duplicate registrations for a state throws an error`() {
runCatching {
createStateMachine<Events, States> {
addInitialState(States.First)
addState<States.First>()
}
}.onSuccess {
fail("It should have thrown an error")
}.onFailure { error ->
assertThat(error.message).startsWith("Duplicate registration for state ")
}
Unit
}
@Test
fun `init - the state machine having duplicate registrations for an event inside a state throws an error`() {
runCatching {
createStateMachine<Events, States> {
addInitialState(States.First) {
on<Events.GoToThird>(States.Third)
on<Events.GoToThird> { _, _ -> States.Third }
}
}
}.onSuccess {
fail("It should have thrown an error")
}.onFailure { error ->
assertThat(error.message).startsWith("Duplicate registration in state")
}
Unit
}
@Test
fun `init - the state machine having duplicate registrations for an event at the root level throws an error`() {
runCatching {
createStateMachine<Events, States> {
addInitialState(States.First)
on<Events.GoToThird>(States.Third)
on<Events.GoToThird>(States.Third)
}
}.onSuccess {
fail("It should have thrown an error")
}.onFailure { error ->
assertThat(error.message).startsWith("Duplicate registration in state")
}
Unit
}
}

View file

@ -196,6 +196,14 @@ object ElementTextStyles {
textAlign = TextAlign.Start
)
val bodyMD = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Normal,
fontStyle = FontStyle.Normal,
lineHeight = 20.sp,
textAlign = TextAlign.Start
)
val footnote = TextStyle(
fontSize = 13.sp,
fontWeight = FontWeight.Normal,

View file

@ -16,11 +16,15 @@
package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.progressSemantics
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun CircularProgressIndicator(
@ -49,3 +53,18 @@ fun CircularProgressIndicator(
strokeWidth = strokeWidth,
)
}
@Composable
fun ButtonCircularProgressIndicator(
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.onPrimary,
strokeWidth: Dp = 2.dp,
) {
CircularProgressIndicator(
modifier = modifier
.progressSemantics()
.size(18.dp),
color = color,
strokeWidth = strokeWidth,
)
}

View file

@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.media.MediaResolver
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
interface MatrixClient {
val sessionId: SessionId
@ -29,6 +30,7 @@ interface MatrixClient {
fun startSync()
fun stopSync()
fun mediaResolver(): MediaResolver
fun sessionVerificationService(): SessionVerificationService
suspend fun logout()
suspend fun loadUserDisplayName(): Result<String>
suspend fun loadUserAvatarURLString(): Result<String>
@ -38,4 +40,6 @@ interface MatrixClient {
width: Long,
height: Long
): Result<ByteArray>
fun onSlidingSyncUpdate()
}

View file

@ -0,0 +1,105 @@
/*
* 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.libraries.matrix.api.verification
import kotlinx.coroutines.flow.StateFlow
interface SessionVerificationService {
/**
* State of the current verification flow ([VerificationFlowState.Initial] if not started).
*/
val verificationFlowState : StateFlow<VerificationFlowState>
/**
* The internal service that checks verification can only run after the initial sync.
* This [StateFlow] will notify consumers when the service is ready to be used.
*/
val isReady: StateFlow<Boolean>
/**
* Returns whether the current verification status is either: [SessionVerifiedStatus.Unknown], [SessionVerifiedStatus.NotVerified]
* or [SessionVerifiedStatus.Verified].
*/
val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus>
/**
* Request verification of the current session.
*/
fun requestVerification()
/**
* Cancels the current verification attempt.
*/
fun cancelVerification()
/**
* Approves the current verification. This must happen on both devices to successfully verify a session.
*/
fun approveVerification()
/**
* Declines the verification attempt because the user could not verify or does not trust the other side of the verification.
*/
fun declineVerification()
/**
* Starts the verification of the unverified session from another device.
*/
fun startVerification()
/**
* Returns the verification service state to the initial step.
*/
fun reset()
}
/** Verification status of the current session. */
sealed interface SessionVerifiedStatus {
/** Unknown status, we couldn't read the actual value from the SDK. */
object Unknown : SessionVerifiedStatus
/** Not verified session status. */
object NotVerified : SessionVerifiedStatus
/** Verified session status. */
object Verified : SessionVerifiedStatus
}
/** States produced by the [SessionVerificationService]. */
sealed interface VerificationFlowState {
/** Initial state. */
object Initial : VerificationFlowState
/** Session verification request was accepted by another device. */
object AcceptedVerificationRequest : VerificationFlowState
/** Short Authentication String (SAS) verification started between the 2 devices. */
object StartedSasVerification : VerificationFlowState
/** Verification data for the SAS verification (emojis) received. */
data class ReceivedVerificationData(val emoji: List<VerificationEmoji>) : VerificationFlowState
/** Verification completed successfully. */
object Finished : VerificationFlowState
/** Verification was cancelled by either device. */
object Canceled : VerificationFlowState
/** Verification failed with an error. */
object Failed : VerificationFlowState
}

View file

@ -0,0 +1,22 @@
/*
* 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.libraries.matrix.api.verification
data class VerificationEmoji(
val code: String,
val name: String,
)

View file

@ -23,12 +23,17 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaResolver
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.impl.media.RustMediaResolver
import io.element.android.libraries.matrix.impl.room.RustMatrixRoom
import io.element.android.libraries.matrix.impl.room.RustRoomSummaryDataSource
import io.element.android.libraries.matrix.impl.sync.SlidingSyncObserverProxy
import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientDelegate
@ -53,6 +58,9 @@ class RustMatrixClient constructor(
override val sessionId: UserId = UserId(client.userId())
private val verificationService = RustSessionVerificationService()
private var slidingSyncUpdateJob: Job? = null
private val clientDelegate = object : ClientDelegate {
override fun didReceiveAuthError(isSoftLogout: Boolean) {
Timber.v("didReceiveAuthError()")
@ -131,6 +139,9 @@ class RustMatrixClient constructor(
client.setDelegate(clientDelegate)
rustRoomSummaryDataSource.init()
slidingSync.setObserver(slidingSyncObserverProxy)
slidingSyncUpdateJob = slidingSyncObserverProxy.updateSummaryFlow
.onEach { onSlidingSyncUpdate() }
.launchIn(coroutineScope)
}
private fun onRestartSync() {
@ -152,6 +163,8 @@ class RustMatrixClient constructor(
override fun mediaResolver(): MediaResolver = mediaResolver
override fun sessionVerificationService(): SessionVerificationService = verificationService
override fun startSync() {
if (client.isSoftLogout()) return
if (isSyncing.compareAndSet(false, true)) {
@ -166,12 +179,14 @@ class RustMatrixClient constructor(
}
private fun close() {
slidingSyncUpdateJob?.cancel()
stopSync()
slidingSync.setObserver(null)
rustRoomSummaryDataSource.close()
client.setDelegate(null)
visibleRoomsView.destroy()
slidingSync.destroy()
verificationService.destroy()
}
override suspend fun logout() = withContext(dispatchers.io) {
@ -226,6 +241,16 @@ class RustMatrixClient constructor(
}
}
override fun onSlidingSyncUpdate() {
if (!verificationService.isReady.value) {
try {
verificationService.verificationController = client.getSessionVerificationController()
} catch (e: Throwable) {
Timber.e(e, "Could not start verification service. Will try again on the next sliding sync update.")
}
}
}
private fun File.deleteSessionDirectory(userID: String): Boolean {
// Rust sanitises the user ID replacing invalid characters with an _
val sanitisedUserID = userID.replace(":", "_")

View file

@ -0,0 +1,35 @@
/*
* 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.libraries.matrix.impl.di
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
@Module
@ContributesTo(SessionScope::class)
object SessionMatrixModule {
@Provides
@SingleIn(SessionScope::class)
fun providesRustSessionVerificationService(matrixClient: MatrixClient): SessionVerificationService {
return matrixClient.sessionVerificationService()
}
}

View file

@ -36,7 +36,6 @@ class SlidingSyncObserverProxy(
val updateSummaryFlow: SharedFlow<UpdateSummary> = updateSummaryMutableFlow.asSharedFlow()
override fun didReceiveSyncUpdate(summary: UpdateSummary) {
if (summary.rooms.isEmpty()) return
coroutineScope.launch {
updateSummaryMutableFlow.emit(summary)
}

View file

@ -0,0 +1,132 @@
/*
* 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.libraries.matrix.impl.verification
import io.element.android.libraries.core.data.tryOrNull
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.api.verification.VerificationEmoji
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.matrix.rustcomponents.sdk.SessionVerificationController
import org.matrix.rustcomponents.sdk.SessionVerificationControllerDelegate
import org.matrix.rustcomponents.sdk.SessionVerificationControllerInterface
import org.matrix.rustcomponents.sdk.SessionVerificationEmoji
import javax.inject.Inject
class RustSessionVerificationService @Inject constructor() : SessionVerificationService, SessionVerificationControllerDelegate {
var verificationController: SessionVerificationControllerInterface? = null
set(value) {
field = value
_isReady.value = value != null
// If status was 'Unknown', move it to either 'Verified' or 'NotVerified'
if (value != null) {
updateVerificationStatus(value.isVerified())
}
}
private val _verificationFlowState = MutableStateFlow<VerificationFlowState>(VerificationFlowState.Initial)
override val verificationFlowState = _verificationFlowState.asStateFlow()
private val _isReady = MutableStateFlow(false)
override val isReady = _isReady.asStateFlow()
private val _sessionVerifiedStatus = MutableStateFlow<SessionVerifiedStatus>(SessionVerifiedStatus.Unknown)
override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus.asStateFlow()
override fun requestVerification() = tryOrFail {
verificationController?.setDelegate(this)
verificationController?.requestVerification()
}
override fun cancelVerification() = tryOrFail { verificationController?.cancelVerification() }
override fun approveVerification() = tryOrFail { verificationController?.approveVerification() }
override fun declineVerification() = tryOrFail { verificationController?.declineVerification() }
override fun startVerification() = tryOrFail {
verificationController?.setDelegate(this)
verificationController?.startSasVerification()
}
private fun tryOrFail(block: () -> Unit) {
runCatching {
block()
}.onFailure { didFail() }
}
// region Delegate implementation
// When verification attempt is accepted by the other device
override fun didAcceptVerificationRequest() {
_verificationFlowState.value = VerificationFlowState.AcceptedVerificationRequest
}
override fun didCancel() {
_verificationFlowState.value = VerificationFlowState.Canceled
}
override fun didFail() {
_verificationFlowState.value = VerificationFlowState.Failed
}
override fun didFinish() {
_verificationFlowState.value = VerificationFlowState.Finished
// Ideally this should be `verificationController?.isVerified().orFalse()` but for some reason it always returns false
updateVerificationStatus(isVerified = true)
}
override fun didReceiveVerificationData(data: List<SessionVerificationEmoji>) {
val emojis = data.map { emoji ->
emoji.use { VerificationEmoji(it.symbol(), it.description()) }
}
_verificationFlowState.value = VerificationFlowState.ReceivedVerificationData(emojis)
}
// When the actual SAS verification starts
override fun didStartSasVerification() {
_verificationFlowState.value = VerificationFlowState.StartedSasVerification
}
// end-region
override fun reset() {
if (isReady.value) {
// Cancel any pending verification attempt
tryOrNull { verificationController?.cancelVerification() }
}
_verificationFlowState.value = VerificationFlowState.Initial
}
fun destroy() {
(verificationController as? SessionVerificationController)?.destroy()
verificationController = null
}
private fun updateVerificationStatus(isVerified: Boolean) {
val newValue = when {
!isReady.value -> SessionVerifiedStatus.Unknown
!isVerified -> SessionVerifiedStatus.NotVerified
else -> SessionVerifiedStatus.Verified
}
_sessionVerifiedStatus.value = newValue
}
}

View file

@ -22,16 +22,19 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.media.MediaResolver
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.test.media.FakeMediaResolver
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import kotlinx.coroutines.delay
class FakeMatrixClient(
override val sessionId: SessionId = A_SESSION_ID,
private val userDisplayName: Result<String> = Result.success(A_USER_NAME),
private val userAvatarURLString: Result<String> = Result.success(AN_AVATAR_URL),
override val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource()
override val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(),
private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService()
) : MatrixClient {
private var logoutFailure: Throwable? = null
@ -72,4 +75,8 @@ class FakeMatrixClient(
override suspend fun loadMediaThumbnail(url: String, width: Long, height: Long): Result<ByteArray> {
return Result.success(ByteArray(0))
}
override fun sessionVerificationService(): SessionVerificationService = sessionVerificationService
override fun onSlidingSyncUpdate() {}
}

View file

@ -0,0 +1,90 @@
/*
* 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.libraries.matrix.test.verification
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.api.verification.VerificationEmoji
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class FakeSessionVerificationService : SessionVerificationService {
private val _isReady = MutableStateFlow(false)
private val _sessionVerifiedStatus = MutableStateFlow<SessionVerifiedStatus>(SessionVerifiedStatus.Unknown)
private var _verificationFlowState = MutableStateFlow<VerificationFlowState>(VerificationFlowState.Initial)
private var emojiList = emptyList<VerificationEmoji>()
var shouldFail = false
override val verificationFlowState: StateFlow<VerificationFlowState>
get() = _verificationFlowState
override val sessionVerifiedStatus: StateFlow<SessionVerifiedStatus> = _sessionVerifiedStatus
override val isReady: StateFlow<Boolean> = _isReady
override fun requestVerification() {
_verificationFlowState.value = VerificationFlowState.AcceptedVerificationRequest
_verificationFlowState.value = VerificationFlowState.StartedSasVerification
_verificationFlowState.value = VerificationFlowState.ReceivedVerificationData(emojiList)
}
override fun cancelVerification() {
_verificationFlowState.value = VerificationFlowState.Canceled
}
override fun approveVerification() {
if (!shouldFail) {
_verificationFlowState.value = VerificationFlowState.Finished
} else {
_verificationFlowState.value = VerificationFlowState.Failed
}
}
override fun declineVerification() {
if (!shouldFail) {
_verificationFlowState.value = VerificationFlowState.Canceled
} else {
_verificationFlowState.value = VerificationFlowState.Failed
}
}
override fun startVerification() {
_verificationFlowState.value = VerificationFlowState.StartedSasVerification
_verificationFlowState.value = VerificationFlowState.ReceivedVerificationData(emojiList)
}
fun givenVerifiedStatus(status: SessionVerifiedStatus) {
_sessionVerifiedStatus.value = status
}
fun givenVerificationFlowState(state: VerificationFlowState) {
_verificationFlowState.value = state
}
fun givenIsReady(value: Boolean) {
_isReady.value = value
}
fun givenEmojiList(emojis: List<VerificationEmoji>) {
this.emojiList = emojis
}
override fun reset() {
_verificationFlowState.value = VerificationFlowState.Initial
}
}

View file

@ -19,4 +19,29 @@
<string name="search_for_someone">Search for someone</string>
<string name="new_room">New room</string>
<string name="verification_title_initial">Open an existing session</string>
<string name="verification_title_waiting">Waiting to accept request</string>
<string name="verification_title_canceled">Verification cancelled</string>
<string name="verification_title_verifying">Compare emojis</string>
<string name="verification_subtitle_initial">Prove it\'s you in order to access your encrypted message history.</string>
<string name="verification_subtitle_waiting">Accept the request to start the verification process in your other session to continue.</string>
<string name="verification_subtitle_canceled">Something doesn\'t seem right. Either the request timed out or the request was denied.</string>
<string name="verification_subtitle_verifying">Confirm that the emojis below match those shown on your other session.</string>
<string name="verification_positive_button_initial">I am ready</string>
<string name="verification_positive_button_canceled">Retry verification</string>
<string name="verification_positive_button_verifying_start">They match</string>
<string name="verification_positive_button_verifying_ongoing">Waiting to match</string>
<string name="verification_negative_button_initial">@string/action_cancel</string>
<string name="verification_negative_button_canceled">@string/action_cancel</string>
<string name="verification_negative_button_verifying">They don\'t match</string>
<string name="session_verification_banner_title">Access your message history</string>
<string name="session_verification_banner_message">Looks like you\'re using a new device. Verify it\'s you to access your encrypted messages.</string>
<string name="session_verification_start">Continue</string>
<string name="verification_conclusion_ok_self_notice_title">Verification complete</string>
</resources>

View file

@ -76,6 +76,7 @@ fun DependencyHandlerScope.allFeaturesApi() {
implementation(project(":features:rageshake:api"))
implementation(project(":features:preferences:api"))
implementation(project(":features:createroom:api"))
implementation(project(":features:verifysession:api"))
}
fun DependencyHandlerScope.allFeaturesImpl() {
@ -87,4 +88,5 @@ fun DependencyHandlerScope.allFeaturesImpl() {
implementation(project(":features:rageshake:impl"))
implementation(project(":features:preferences:impl"))
implementation(project(":features:createroom:impl"))
implementation(project(":features:verifysession:impl"))
}

View file

@ -40,7 +40,8 @@ class RoomListScreen(
private val timeZone = TimeZone.currentSystemDefault()
private val dateTimeProvider = LocalDateTimeProvider(clock, timeZone)
private val dateFormatters = DateFormatters(locale, clock, timeZone)
private val presenter = RoomListPresenter(matrixClient, DefaultLastMessageFormatter(dateTimeProvider, dateFormatters))
private val sessionVerificationService = matrixClient.sessionVerificationService()
private val presenter = RoomListPresenter(matrixClient, DefaultLastMessageFormatter(dateTimeProvider, dateFormatters), sessionVerificationService)
@Composable
fun Content(modifier: Modifier = Modifier) {

View file

@ -85,3 +85,5 @@ include(":features:login:api")
include(":features:login:impl")
include(":features:createroom:api")
include(":features:createroom:impl")
include(":features:verifysession:api")
include(":features:verifysession:impl")

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:474c799a08ccba7c8b4d9ba3d120b103d0e8bb9654bb4da576d4eee579e5d517
size 28222

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c705a9c97a0bc5a2987cef4db6fd9b79de1cf1f5405035c6371615fb72fc5f36
size 27715

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f653439749b16d9f683b63984855e2dad576e5d567eeed792f28e9484b12b67f
size 60599

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0fc272268179409483fc5fad89aa00714a6811c63e478b5c696d4ab0338ce6bf
size 37781

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d88db44fb66340043d83e256b1eb7655e66af5e11daa55bd1336b4ff6e57883d
size 59472

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:43b69859fa3ee38d2b7f7415b87738db65dc6dac3d2fabddc1f1346b0b64932b
size 37329

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3d474dbae506ecab03cf32414bd4af4c16c934d49be170c6572f2492c6362350
size 28356

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:745725187e65e850f04937783bbf99c3cc805dd3a9ce7580ac479c9ab2b0f9e1
size 27829

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:07e72c29b3a928eaa86ec7c97cb680cf2eaacebf6512390eb9dda47bdc2a9c7e
size 29249

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:76b4ac8395ac876c4ff979bd92279b9735fae581987326c6ab929e8578335c5a
size 26657

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ca4957bb6995efbf398bf211da38cafb6b2c1693167f9c940d6a31343bfcf127
size 61454

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7590a2604bb93de8a416e7e45b6c410c27fd83481cfb08f3f41e820d22fc5582
size 62046

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4861b0c969a8af115ad2638b54f5893e813633a61bebb8bccafa01cfda320ea9
size 31937

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4ee6ee256e59932e2d6aa29c393ec30499da06c6759ed33e8298838618bbfb9f
size 28465

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:586954a4fae4405f47a02140ec0ea9c89b2f6becc1b9b852d0a8fa28e10bf089
size 25959

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:083f2c5ba49a4579015c38ffa2e0c2dd4ed04f389faa4c90cf6afa6438e08ca7
size 59174

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ed5e914779a3dcfbed0af73cace4e9141259932a892ae57ee902aec4860bd879
size 59628

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2c8534d5464f3ee2551df199d18af1589f6c1e0c66292756be1e1af9deee1b17
size 31580

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c7513e7f34cb817fee0f74ca4a00204cd5d8b0928f80afdf487a9cc8e43a1fc8
size 29289

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:280505afce4b14d28a13a2a4e16dc1bc0a5e9e350c9937d185fe19746ba2a415
size 26658

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0e9a966e2f3161eac1aedf146d033a47a9724b562dd07bffc3bb88b1e5ed00b2
size 61502

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bd3546ed310fb679f6226af97c5832f15a162906d21c15805cd19ecf5690a945
size 62094

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2ea9c30f0f450962a591ad4e13ea19a2e8b13f863359c20d1edd3fec6675811d
size 31902

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:42ab717a579388fe13d1b5438c82348527f48f8387ee4336cf4d5483e1c1d4af
size 28512

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4fe51076b770fabdda09687fed36a500bf05295f3cc599fc524cc87831a65a83
size 25949

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e8136c7d1fe030e3575f7929c20ad22dd7d002143a57d934b6252d9eb0845f55
size 59181

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:88cc80bb7272ef9fa012c8f394f8136f228fcf5903e65e7fb5ba03679b1ed0cd
size 59625

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ae2530c339d94a6a615df644950e54d3b442e09e0721d036b26386bfa75525fe
size 31580