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 18c45ad620
commit 9639d62bb3
76 changed files with 2347 additions and 35 deletions

View File

@@ -45,6 +45,7 @@ dependencies {
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.features.verifysession.api)
implementation(projects.tests.uitests) implementation(projects.tests.uitests)
implementation(libs.coil) 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.createroom.api.CreateRoomEntryPoint
import io.element.android.features.preferences.api.PreferencesEntryPoint import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.features.roomlist.api.RoomListEntryPoint 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.BackstackNode
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
@@ -61,6 +62,7 @@ class LoggedInFlowNode @AssistedInject constructor(
private val preferencesEntryPoint: PreferencesEntryPoint, private val preferencesEntryPoint: PreferencesEntryPoint,
private val createRoomEntryPoint: CreateRoomEntryPoint, private val createRoomEntryPoint: CreateRoomEntryPoint,
private val appNavigationStateService: AppNavigationStateService, private val appNavigationStateService: AppNavigationStateService,
private val verifySessionEntryPoint: VerifySessionEntryPoint,
) : BackstackNode<LoggedInFlowNode.NavTarget>( ) : BackstackNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.RoomList, initialElement = NavTarget.RoomList,
@@ -120,6 +122,9 @@ class LoggedInFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
object CreateRoom : NavTarget object CreateRoom : NavTarget
@Parcelize
object VerifySession : NavTarget
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@@ -137,6 +142,10 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun onCreateRoomClicked() { override fun onCreateRoomClicked() {
backstack.push(NavTarget.CreateRoom) backstack.push(NavTarget.CreateRoom)
} }
override fun onSessionVerificationClicked() {
backstack.push(NavTarget.VerifySession)
}
} }
roomListEntryPoint roomListEntryPoint
.nodeBuilder(this, buildContext) .nodeBuilder(this, buildContext)
@@ -171,6 +180,9 @@ class LoggedInFlowNode @AssistedInject constructor(
NavTarget.CreateRoom -> { NavTarget.CreateRoom -> {
createRoomEntryPoint.createNode(this, buildContext) 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.elementresources)
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.dateformatter.api)
implementation(libs.accompanist.placeholder)
api(projects.features.logout.api) api(projects.features.logout.api)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)

View File

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

View File

@@ -19,4 +19,6 @@ package io.element.android.features.roomlist.impl
sealed interface RoomListEvents { sealed interface RoomListEvents {
data class UpdateFilter(val newFilter: String) : RoomListEvents data class UpdateFilter(val newFilter: String) : RoomListEvents
data class UpdateVisibleRange(val range: IntRange) : 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() } plugins<RoomListEntryPoint.Callback>().forEach { it.onCreateRoomClicked() }
} }
private fun onSessionVerificationClicked() {
plugins<RoomListEntryPoint.Callback>().forEach { it.onSessionVerificationClicked() }
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
@@ -57,6 +61,7 @@ class RoomListNode @AssistedInject constructor(
onRoomClicked = this::onRoomClicked, onRoomClicked = this::onRoomClicked,
onOpenSettings = this::onOpenSettings, onOpenSettings = this::onOpenSettings,
onCreateRoomClicked = this::onCreateRoomClicked, 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.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.MatrixClient
import io.element.android.libraries.matrix.api.core.UserId 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.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 io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@@ -49,6 +53,7 @@ private const val extendedRangeSize = 40
class RoomListPresenter @Inject constructor( class RoomListPresenter @Inject constructor(
private val client: MatrixClient, private val client: MatrixClient,
private val lastMessageFormatter: LastMessageFormatter, private val lastMessageFormatter: LastMessageFormatter,
private val sessionVerificationService: SessionVerificationService,
) : Presenter<RoomListState> { ) : Presenter<RoomListState> {
@Composable @Composable
@@ -71,20 +76,40 @@ class RoomListPresenter @Inject constructor(
initialLoad(matrixUser) 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) { fun handleEvents(event: RoomListEvents) {
when (event) { when (event) {
is RoomListEvents.UpdateFilter -> filter = event.newFilter is RoomListEvents.UpdateFilter -> filter = event.newFilter
is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range) is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range)
RoomListEvents.DismissRequestVerificationPrompt -> verificationPromptDismissed = true
RoomListEvents.ClearSuccessfulVerificationMessage -> sessionVerificationService.reset()
} }
} }
LaunchedEffect(roomSummaries, filter) { LaunchedEffect(roomSummaries, filter) {
filteredRoomSummaries.value = updateFilteredRoomSummaries(roomSummaries, filter) filteredRoomSummaries.value = updateFilteredRoomSummaries(roomSummaries, filter)
} }
return RoomListState( return RoomListState(
matrixUser = matrixUser.value, matrixUser = matrixUser.value,
roomList = filteredRoomSummaries.value, roomList = filteredRoomSummaries.value,
filter = filter, filter = filter,
presentVerificationSuccessfulMessage = presentVerificationSuccessfulMessage.value,
displayVerificationPrompt = displayVerificationPrompt,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }

View File

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

View File

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

View File

@@ -16,16 +16,31 @@
package io.element.android.features.roomlist.impl 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.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.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState 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.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme 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.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember 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.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource 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.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Velocity 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.RoomListTopBar
import io.element.android.features.roomlist.impl.components.RoomSummaryRow import io.element.android.features.roomlist.impl.components.RoomSummaryRow
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary 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.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight 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.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.Icon 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.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.designsystem.utils.LogCompositions
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.model.MatrixUser import io.element.android.libraries.matrix.ui.model.MatrixUser
@@ -57,40 +78,27 @@ fun RoomListView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onRoomClicked: (RoomId) -> Unit = {}, onRoomClicked: (RoomId) -> Unit = {},
onOpenSettings: () -> Unit = {}, onOpenSettings: () -> Unit = {},
onVerifyClicked: () -> Unit = {},
onCreateRoomClicked: () -> Unit = {}, onCreateRoomClicked: () -> Unit = {},
) { ) {
fun onFilterChanged(filter: String) {
state.eventSink(RoomListEvents.UpdateFilter(filter))
}
fun onVisibleRangedChanged(range: IntRange) {
state.eventSink(RoomListEvents.UpdateVisibleRange(range))
}
RoomListContent( RoomListContent(
roomSummaries = state.roomList, state = state,
matrixUser = state.matrixUser,
filter = state.filter,
modifier = modifier, modifier = modifier,
onRoomClicked = onRoomClicked, onRoomClicked = onRoomClicked,
onFilterChanged = ::onFilterChanged,
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
onScrollOver = ::onVisibleRangedChanged, onVerifyClicked = onVerifyClicked,
onCreateRoomClicked = onCreateRoomClicked, onCreateRoomClicked = onCreateRoomClicked,
) )
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
fun RoomListContent( fun RoomListContent(
roomSummaries: ImmutableList<RoomListRoomSummary>, state: RoomListState,
matrixUser: MatrixUser?,
filter: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onVerifyClicked: () -> Unit = {},
onRoomClicked: (RoomId) -> Unit = {}, onRoomClicked: (RoomId) -> Unit = {},
onFilterChanged: (String) -> Unit = {},
onOpenSettings: () -> Unit = {}, onOpenSettings: () -> Unit = {},
onScrollOver: (IntRange) -> Unit = {},
onCreateRoomClicked: () -> Unit = {}, onCreateRoomClicked: () -> Unit = {},
) { ) {
fun onRoomClicked(room: RoomListRoomSummary) { fun onRoomClicked(room: RoomListRoomSummary) {
@@ -117,19 +125,31 @@ fun RoomListContent(
val nestedScrollConnection = remember { val nestedScrollConnection = remember {
object : NestedScrollConnection { object : NestedScrollConnection {
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
onScrollOver(visibleRange) state.eventSink(RoomListEvents.UpdateVisibleRange(visibleRange))
return super.onPostFling(consumed, available) 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( Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { topBar = {
RoomListTopBar( RoomListTopBar(
matrixUser = matrixUser, matrixUser = state.matrixUser,
filter = filter, filter = state.filter,
onFilterChanged = onFilterChanged, onFilterChanged = { state.eventSink(RoomListEvents.UpdateFilter(it)) },
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
modifier = Modifier, modifier = Modifier,
@@ -146,8 +166,16 @@ fun RoomListContent(
.nestedScroll(nestedScrollConnection), .nestedScroll(nestedScrollConnection),
state = lazyListState, state = lazyListState,
) { ) {
if (state.displayVerificationPrompt) {
item {
RequestVerificationHeader(
onVerifyClicked = onVerifyClicked,
onDismissClicked = { state.eventSink(RoomListEvents.DismissRequestVerificationPrompt) }
)
}
}
items( items(
items = roomSummaries, items = state.roomList,
contentType = { room -> room.contentType() }, contentType = { room -> room.contentType() },
) { room -> ) { room ->
RoomSummaryRow(room = room, onClick = ::onRoomClicked) 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)) 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 private fun RoomListRoomSummary.contentType() = isPlaceholder
@Preview @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.api.LastMessageFormatter
import io.element.android.libraries.dateformatter.test.FakeLastMessageFormatter import io.element.android.libraries.dateformatter.test.FakeLastMessageFormatter
import io.element.android.libraries.designsystem.components.avatar.AvatarData 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_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_MESSAGE 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.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource 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.room.aRoomSummaryFilled
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@@ -44,7 +47,8 @@ class RoomListPresenterTests {
fun `present - should start with no user and then load user with success`() = runTest { fun `present - should start with no user and then load user with success`() = runTest {
val presenter = RoomListPresenter( val presenter = RoomListPresenter(
FakeMatrixClient(A_SESSION_ID), FakeMatrixClient(A_SESSION_ID),
createDateFormatter() createDateFormatter(),
FakeSessionVerificationService(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@@ -68,7 +72,8 @@ class RoomListPresenterTests {
userDisplayName = Result.failure(AN_EXCEPTION), userDisplayName = Result.failure(AN_EXCEPTION),
userAvatarURLString = Result.failure(AN_EXCEPTION), userAvatarURLString = Result.failure(AN_EXCEPTION),
), ),
createDateFormatter() createDateFormatter(),
FakeSessionVerificationService(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@@ -86,7 +91,8 @@ class RoomListPresenterTests {
fun `present - should filter room with success`() = runTest { fun `present - should filter room with success`() = runTest {
val presenter = RoomListPresenter( val presenter = RoomListPresenter(
FakeMatrixClient(A_SESSION_ID), FakeMatrixClient(A_SESSION_ID),
createDateFormatter() createDateFormatter(),
FakeSessionVerificationService(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@@ -108,7 +114,8 @@ class RoomListPresenterTests {
sessionId = A_SESSION_ID, sessionId = A_SESSION_ID,
roomSummaryDataSource = roomSummaryDataSource roomSummaryDataSource = roomSummaryDataSource
), ),
createDateFormatter() createDateFormatter(),
FakeSessionVerificationService(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@@ -135,7 +142,8 @@ class RoomListPresenterTests {
sessionId = A_SESSION_ID, sessionId = A_SESSION_ID,
roomSummaryDataSource = roomSummaryDataSource roomSummaryDataSource = roomSummaryDataSource
), ),
createDateFormatter() createDateFormatter(),
FakeSessionVerificationService(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@@ -167,7 +175,8 @@ class RoomListPresenterTests {
sessionId = A_SESSION_ID, sessionId = A_SESSION_ID,
roomSummaryDataSource = roomSummaryDataSource roomSummaryDataSource = roomSummaryDataSource
), ),
createDateFormatter() createDateFormatter(),
FakeSessionVerificationService(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() 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 { private fun createDateFormatter(): LastMessageFormatter {
return FakeLastMessageFormatter().apply { return FakeLastMessageFormatter().apply {
givenFormat(A_FORMATTED_DATE) givenFormat(A_FORMATTED_DATE)
@@ -221,4 +280,3 @@ private val aRoomListRoomSummary = RoomListRoomSummary(
avatarData = AvatarData(id = A_ROOM_ID.value, name = A_ROOM_NAME), avatarData = AvatarData(id = A_ROOM_ID.value, name = A_ROOM_NAME),
isPlaceholder = false, 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 { dependencies {
implementation(libs.coroutines.core) 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 textAlign = TextAlign.Start
) )
val bodyMD = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Normal,
fontStyle = FontStyle.Normal,
lineHeight = 20.sp,
textAlign = TextAlign.Start
)
val footnote = TextStyle( val footnote = TextStyle(
fontSize = 13.sp, fontSize = 13.sp,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,

View File

@@ -16,11 +16,15 @@
package io.element.android.libraries.designsystem.theme.components 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.material3.ProgressIndicatorDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable @Composable
fun CircularProgressIndicator( fun CircularProgressIndicator(
@@ -49,3 +53,18 @@ fun CircularProgressIndicator(
strokeWidth = strokeWidth, 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.media.MediaResolver
import io.element.android.libraries.matrix.api.room.MatrixRoom 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.room.RoomSummaryDataSource
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
interface MatrixClient { interface MatrixClient {
val sessionId: SessionId val sessionId: SessionId
@@ -29,6 +30,7 @@ interface MatrixClient {
fun startSync() fun startSync()
fun stopSync() fun stopSync()
fun mediaResolver(): MediaResolver fun mediaResolver(): MediaResolver
fun sessionVerificationService(): SessionVerificationService
suspend fun logout() suspend fun logout()
suspend fun loadUserDisplayName(): Result<String> suspend fun loadUserDisplayName(): Result<String>
suspend fun loadUserAvatarURLString(): Result<String> suspend fun loadUserAvatarURLString(): Result<String>
@@ -38,4 +40,6 @@ interface MatrixClient {
width: Long, width: Long,
height: Long height: Long
): Result<ByteArray> ): 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.media.MediaResolver
import io.element.android.libraries.matrix.api.room.MatrixRoom 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.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.media.RustMediaResolver
import io.element.android.libraries.matrix.impl.room.RustMatrixRoom 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.room.RustRoomSummaryDataSource
import io.element.android.libraries.matrix.impl.sync.SlidingSyncObserverProxy 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 io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientDelegate import org.matrix.rustcomponents.sdk.ClientDelegate
@@ -53,6 +58,9 @@ class RustMatrixClient constructor(
override val sessionId: UserId = UserId(client.userId()) override val sessionId: UserId = UserId(client.userId())
private val verificationService = RustSessionVerificationService()
private var slidingSyncUpdateJob: Job? = null
private val clientDelegate = object : ClientDelegate { private val clientDelegate = object : ClientDelegate {
override fun didReceiveAuthError(isSoftLogout: Boolean) { override fun didReceiveAuthError(isSoftLogout: Boolean) {
Timber.v("didReceiveAuthError()") Timber.v("didReceiveAuthError()")
@@ -131,6 +139,9 @@ class RustMatrixClient constructor(
client.setDelegate(clientDelegate) client.setDelegate(clientDelegate)
rustRoomSummaryDataSource.init() rustRoomSummaryDataSource.init()
slidingSync.setObserver(slidingSyncObserverProxy) slidingSync.setObserver(slidingSyncObserverProxy)
slidingSyncUpdateJob = slidingSyncObserverProxy.updateSummaryFlow
.onEach { onSlidingSyncUpdate() }
.launchIn(coroutineScope)
} }
private fun onRestartSync() { private fun onRestartSync() {
@@ -152,6 +163,8 @@ class RustMatrixClient constructor(
override fun mediaResolver(): MediaResolver = mediaResolver override fun mediaResolver(): MediaResolver = mediaResolver
override fun sessionVerificationService(): SessionVerificationService = verificationService
override fun startSync() { override fun startSync() {
if (client.isSoftLogout()) return if (client.isSoftLogout()) return
if (isSyncing.compareAndSet(false, true)) { if (isSyncing.compareAndSet(false, true)) {
@@ -166,12 +179,14 @@ class RustMatrixClient constructor(
} }
private fun close() { private fun close() {
slidingSyncUpdateJob?.cancel()
stopSync() stopSync()
slidingSync.setObserver(null) slidingSync.setObserver(null)
rustRoomSummaryDataSource.close() rustRoomSummaryDataSource.close()
client.setDelegate(null) client.setDelegate(null)
visibleRoomsView.destroy() visibleRoomsView.destroy()
slidingSync.destroy() slidingSync.destroy()
verificationService.destroy()
} }
override suspend fun logout() = withContext(dispatchers.io) { 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 { private fun File.deleteSessionDirectory(userID: String): Boolean {
// Rust sanitises the user ID replacing invalid characters with an _ // Rust sanitises the user ID replacing invalid characters with an _
val sanitisedUserID = userID.replace(":", "_") 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() val updateSummaryFlow: SharedFlow<UpdateSummary> = updateSummaryMutableFlow.asSharedFlow()
override fun didReceiveSyncUpdate(summary: UpdateSummary) { override fun didReceiveSyncUpdate(summary: UpdateSummary) {
if (summary.rooms.isEmpty()) return
coroutineScope.launch { coroutineScope.launch {
updateSummaryMutableFlow.emit(summary) 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.media.MediaResolver
import io.element.android.libraries.matrix.api.room.MatrixRoom 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.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.media.FakeMediaResolver
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom 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.room.FakeRoomSummaryDataSource
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
class FakeMatrixClient( class FakeMatrixClient(
override val sessionId: SessionId = A_SESSION_ID, override val sessionId: SessionId = A_SESSION_ID,
private val userDisplayName: Result<String> = Result.success(A_USER_NAME), private val userDisplayName: Result<String> = Result.success(A_USER_NAME),
private val userAvatarURLString: Result<String> = Result.success(AN_AVATAR_URL), 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 { ) : MatrixClient {
private var logoutFailure: Throwable? = null private var logoutFailure: Throwable? = null
@@ -72,4 +75,8 @@ class FakeMatrixClient(
override suspend fun loadMediaThumbnail(url: String, width: Long, height: Long): Result<ByteArray> { override suspend fun loadMediaThumbnail(url: String, width: Long, height: Long): Result<ByteArray> {
return Result.success(ByteArray(0)) 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="search_for_someone">Search for someone</string>
<string name="new_room">New room</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> </resources>

View File

@@ -76,6 +76,7 @@ fun DependencyHandlerScope.allFeaturesApi() {
implementation(project(":features:rageshake:api")) implementation(project(":features:rageshake:api"))
implementation(project(":features:preferences:api")) implementation(project(":features:preferences:api"))
implementation(project(":features:createroom:api")) implementation(project(":features:createroom:api"))
implementation(project(":features:verifysession:api"))
} }
fun DependencyHandlerScope.allFeaturesImpl() { fun DependencyHandlerScope.allFeaturesImpl() {
@@ -87,4 +88,5 @@ fun DependencyHandlerScope.allFeaturesImpl() {
implementation(project(":features:rageshake:impl")) implementation(project(":features:rageshake:impl"))
implementation(project(":features:preferences:impl")) implementation(project(":features:preferences:impl"))
implementation(project(":features:createroom: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 timeZone = TimeZone.currentSystemDefault()
private val dateTimeProvider = LocalDateTimeProvider(clock, timeZone) private val dateTimeProvider = LocalDateTimeProvider(clock, timeZone)
private val dateFormatters = DateFormatters(locale, 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 @Composable
fun Content(modifier: Modifier = Modifier) { fun Content(modifier: Modifier = Modifier) {

View File

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