Add Session Verification flow (#197)
This commit is contained in:
parent
1795a844a1
commit
dcb98f06aa
76 changed files with 2347 additions and 35 deletions
|
|
@ -45,6 +45,7 @@ dependencies {
|
|||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.features.verifysession.api)
|
||||
implementation(projects.tests.uitests)
|
||||
implementation(libs.coil)
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import io.element.android.anvilannotations.ContributesNode
|
|||
import io.element.android.features.createroom.api.CreateRoomEntryPoint
|
||||
import io.element.android.features.preferences.api.PreferencesEntryPoint
|
||||
import io.element.android.features.roomlist.api.RoomListEntryPoint
|
||||
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
|
||||
import io.element.android.libraries.architecture.BackstackNode
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||
|
|
@ -61,6 +62,7 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
private val preferencesEntryPoint: PreferencesEntryPoint,
|
||||
private val createRoomEntryPoint: CreateRoomEntryPoint,
|
||||
private val appNavigationStateService: AppNavigationStateService,
|
||||
private val verifySessionEntryPoint: VerifySessionEntryPoint,
|
||||
) : BackstackNode<LoggedInFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.RoomList,
|
||||
|
|
@ -120,6 +122,9 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
|
||||
@Parcelize
|
||||
object CreateRoom : NavTarget
|
||||
|
||||
@Parcelize
|
||||
object VerifySession : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
|
|
@ -137,6 +142,10 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
override fun onCreateRoomClicked() {
|
||||
backstack.push(NavTarget.CreateRoom)
|
||||
}
|
||||
|
||||
override fun onSessionVerificationClicked() {
|
||||
backstack.push(NavTarget.VerifySession)
|
||||
}
|
||||
}
|
||||
roomListEntryPoint
|
||||
.nodeBuilder(this, buildContext)
|
||||
|
|
@ -171,6 +180,9 @@ class LoggedInFlowNode @AssistedInject constructor(
|
|||
NavTarget.CreateRoom -> {
|
||||
createRoomEntryPoint.createNode(this, buildContext)
|
||||
}
|
||||
NavTarget.VerifySession -> {
|
||||
verifySessionEntryPoint.createNode(this, buildContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
1
changelog.d/89.feature
Normal file
1
changelog.d/89.feature
Normal file
|
|
@ -0,0 +1 @@
|
|||
Add self session verification flow.
|
||||
|
|
@ -40,6 +40,8 @@ dependencies {
|
|||
implementation(projects.libraries.elementresources)
|
||||
implementation(projects.libraries.testtags)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.dateformatter.api)
|
||||
implementation(libs.accompanist.placeholder)
|
||||
api(projects.features.logout.api)
|
||||
ksp(libs.showkase.processor)
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ interface RoomListEntryPoint : FeatureEntryPoint {
|
|||
fun onRoomClicked(roomId: RoomId)
|
||||
fun onCreateRoomClicked()
|
||||
fun onSettingsClicked()
|
||||
|
||||
fun onSessionVerificationClicked()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,4 +19,6 @@ package io.element.android.features.roomlist.impl
|
|||
sealed interface RoomListEvents {
|
||||
data class UpdateFilter(val newFilter: String) : RoomListEvents
|
||||
data class UpdateVisibleRange(val range: IntRange) : RoomListEvents
|
||||
object DismissRequestVerificationPrompt : RoomListEvents
|
||||
object ClearSuccessfulVerificationMessage : RoomListEvents
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,10 @@ class RoomListNode @AssistedInject constructor(
|
|||
plugins<RoomListEntryPoint.Callback>().forEach { it.onCreateRoomClicked() }
|
||||
}
|
||||
|
||||
private fun onSessionVerificationClicked() {
|
||||
plugins<RoomListEntryPoint.Callback>().forEach { it.onSessionVerificationClicked() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
|
|
@ -57,6 +61,7 @@ class RoomListNode @AssistedInject constructor(
|
|||
onRoomClicked = this::onRoomClicked,
|
||||
onOpenSettings = this::onOpenSettings,
|
||||
onCreateRoomClicked = this::onCreateRoomClicked,
|
||||
onVerifyClicked = this::onSessionVerificationClicked,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -35,6 +36,9 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
|||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomSummary
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
|
@ -49,6 +53,7 @@ private const val extendedRangeSize = 40
|
|||
class RoomListPresenter @Inject constructor(
|
||||
private val client: MatrixClient,
|
||||
private val lastMessageFormatter: LastMessageFormatter,
|
||||
private val sessionVerificationService: SessionVerificationService,
|
||||
) : Presenter<RoomListState> {
|
||||
|
||||
@Composable
|
||||
|
|
@ -71,20 +76,40 @@ class RoomListPresenter @Inject constructor(
|
|||
initialLoad(matrixUser)
|
||||
}
|
||||
|
||||
// Session verification status (unknown, not verified, verified)
|
||||
val sessionVerifiedStatus by sessionVerificationService.sessionVerifiedStatus.collectAsState()
|
||||
var verificationPromptDismissed by rememberSaveable { mutableStateOf(false) }
|
||||
// We combine both values to only display the prompt if the session is not verified and it wasn't dismissed
|
||||
val displayVerificationPrompt by remember {
|
||||
derivedStateOf { sessionVerifiedStatus == SessionVerifiedStatus.NotVerified && !verificationPromptDismissed }
|
||||
}
|
||||
|
||||
// Current verification flow status, if any (initial, requesting, accepted, etc.)
|
||||
val currentVerificationFlowStatus by sessionVerificationService.verificationFlowState.collectAsState()
|
||||
// We only care about the 'Finished' state to display the 'verification success' message
|
||||
val presentVerificationSuccessfulMessage = remember {
|
||||
derivedStateOf { currentVerificationFlowStatus == VerificationFlowState.Finished }
|
||||
}
|
||||
|
||||
fun handleEvents(event: RoomListEvents) {
|
||||
when (event) {
|
||||
is RoomListEvents.UpdateFilter -> filter = event.newFilter
|
||||
is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range)
|
||||
RoomListEvents.DismissRequestVerificationPrompt -> verificationPromptDismissed = true
|
||||
RoomListEvents.ClearSuccessfulVerificationMessage -> sessionVerificationService.reset()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(roomSummaries, filter) {
|
||||
filteredRoomSummaries.value = updateFilteredRoomSummaries(roomSummaries, filter)
|
||||
}
|
||||
|
||||
return RoomListState(
|
||||
matrixUser = matrixUser.value,
|
||||
roomList = filteredRoomSummaries.value,
|
||||
filter = filter,
|
||||
presentVerificationSuccessfulMessage = presentVerificationSuccessfulMessage.value,
|
||||
displayVerificationPrompt = displayVerificationPrompt,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,5 +26,7 @@ data class RoomListState(
|
|||
val matrixUser: MatrixUser?,
|
||||
val roomList: ImmutableList<RoomListRoomSummary>,
|
||||
val filter: String,
|
||||
val presentVerificationSuccessfulMessage: Boolean,
|
||||
val displayVerificationPrompt: Boolean,
|
||||
val eventSink: (RoomListEvents) -> Unit
|
||||
)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
|
|||
override val values: Sequence<RoomListState>
|
||||
get() = sequenceOf(
|
||||
aRoomListState(),
|
||||
aRoomListState().copy(displayVerificationPrompt = true),
|
||||
aRoomListState().copy(presentVerificationSuccessfulMessage = true),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -36,7 +38,9 @@ internal fun aRoomListState() = RoomListState(
|
|||
matrixUser = MatrixUser(id = UserId("@id"), username = "User#1", avatarData = AvatarData("@id", "U")),
|
||||
roomList = aRoomListRoomSummaryList(),
|
||||
filter = "filter",
|
||||
eventSink = {}
|
||||
eventSink = {},
|
||||
presentVerificationSuccessfulMessage = false,
|
||||
displayVerificationPrompt = false,
|
||||
)
|
||||
|
||||
internal fun aRoomListRoomSummaryList(): ImmutableList<RoomListRoomSummary> {
|
||||
|
|
|
|||
|
|
@ -16,16 +16,31 @@
|
|||
|
||||
package io.element.android.features.roomlist.impl
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Snackbar
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -33,17 +48,23 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.roomlist.impl.components.RoomListTopBar
|
||||
import io.element.android.features.roomlist.impl.components.RoomSummaryRow
|
||||
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
||||
import io.element.android.libraries.designsystem.ElementTextStyles
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.utils.LogCompositions
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.ui.model.MatrixUser
|
||||
|
|
@ -57,40 +78,27 @@ fun RoomListView(
|
|||
modifier: Modifier = Modifier,
|
||||
onRoomClicked: (RoomId) -> Unit = {},
|
||||
onOpenSettings: () -> Unit = {},
|
||||
onVerifyClicked: () -> Unit = {},
|
||||
onCreateRoomClicked: () -> Unit = {},
|
||||
) {
|
||||
fun onFilterChanged(filter: String) {
|
||||
state.eventSink(RoomListEvents.UpdateFilter(filter))
|
||||
}
|
||||
|
||||
fun onVisibleRangedChanged(range: IntRange) {
|
||||
state.eventSink(RoomListEvents.UpdateVisibleRange(range))
|
||||
}
|
||||
|
||||
RoomListContent(
|
||||
roomSummaries = state.roomList,
|
||||
matrixUser = state.matrixUser,
|
||||
filter = state.filter,
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onRoomClicked = onRoomClicked,
|
||||
onFilterChanged = ::onFilterChanged,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onScrollOver = ::onVisibleRangedChanged,
|
||||
onVerifyClicked = onVerifyClicked,
|
||||
onCreateRoomClicked = onCreateRoomClicked,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun RoomListContent(
|
||||
roomSummaries: ImmutableList<RoomListRoomSummary>,
|
||||
matrixUser: MatrixUser?,
|
||||
filter: String,
|
||||
state: RoomListState,
|
||||
modifier: Modifier = Modifier,
|
||||
onVerifyClicked: () -> Unit = {},
|
||||
onRoomClicked: (RoomId) -> Unit = {},
|
||||
onFilterChanged: (String) -> Unit = {},
|
||||
onOpenSettings: () -> Unit = {},
|
||||
onScrollOver: (IntRange) -> Unit = {},
|
||||
onCreateRoomClicked: () -> Unit = {},
|
||||
) {
|
||||
fun onRoomClicked(room: RoomListRoomSummary) {
|
||||
|
|
@ -117,19 +125,31 @@ fun RoomListContent(
|
|||
val nestedScrollConnection = remember {
|
||||
object : NestedScrollConnection {
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||
onScrollOver(visibleRange)
|
||||
state.eventSink(RoomListEvents.UpdateVisibleRange(visibleRange))
|
||||
return super.onPostFling(consumed, available)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val verificationCompleteMessage = stringResource(StringR.string.verification_conclusion_ok_self_notice_title)
|
||||
LaunchedEffect(state.presentVerificationSuccessfulMessage) {
|
||||
if (state.presentVerificationSuccessfulMessage) {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = verificationCompleteMessage,
|
||||
duration = SnackbarDuration.Short
|
||||
)
|
||||
state.eventSink(RoomListEvents.ClearSuccessfulVerificationMessage)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
RoomListTopBar(
|
||||
matrixUser = matrixUser,
|
||||
filter = filter,
|
||||
onFilterChanged = onFilterChanged,
|
||||
matrixUser = state.matrixUser,
|
||||
filter = state.filter,
|
||||
onFilterChanged = { state.eventSink(RoomListEvents.UpdateFilter(it)) },
|
||||
onOpenSettings = onOpenSettings,
|
||||
scrollBehavior = scrollBehavior,
|
||||
modifier = Modifier,
|
||||
|
|
@ -146,8 +166,16 @@ fun RoomListContent(
|
|||
.nestedScroll(nestedScrollConnection),
|
||||
state = lazyListState,
|
||||
) {
|
||||
if (state.displayVerificationPrompt) {
|
||||
item {
|
||||
RequestVerificationHeader(
|
||||
onVerifyClicked = onVerifyClicked,
|
||||
onDismissClicked = { state.eventSink(RoomListEvents.DismissRequestVerificationPrompt) }
|
||||
)
|
||||
}
|
||||
}
|
||||
items(
|
||||
items = roomSummaries,
|
||||
items = state.roomList,
|
||||
contentType = { room -> room.contentType() },
|
||||
) { room ->
|
||||
RoomSummaryRow(room = room, onClick = ::onRoomClicked)
|
||||
|
|
@ -164,9 +192,80 @@ fun RoomListContent(
|
|||
Icon(resourceId = DrawableR.drawable.ic_edit_square, contentDescription = stringResource(id = StringR.string.a11y_create_message))
|
||||
}
|
||||
},
|
||||
snackbarHost = {
|
||||
SnackbarHost (snackbarHostState) { data ->
|
||||
Snackbar(
|
||||
snackbarData = data,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun RequestVerificationHeader(
|
||||
onVerifyClicked: () -> Unit,
|
||||
onDismissClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Surface(
|
||||
modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
Row {
|
||||
Text(
|
||||
stringResource(StringR.string.session_verification_banner_title),
|
||||
modifier = Modifier.weight(1f),
|
||||
style = ElementTextStyles.Bold.body,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
Icon(
|
||||
modifier = Modifier.clickable(onClick = onDismissClicked),
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = stringResource(StringR.string.action_close)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(stringResource(StringR.string.session_verification_banner_message), style = ElementTextStyles.Regular.bodyMD)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 7.dp),
|
||||
onClick = onVerifyClicked,
|
||||
) {
|
||||
Text(stringResource(StringR.string.session_verification_start), style = ElementTextStyles.Button)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun PreviewRequestVerificationHeaderLight() {
|
||||
ElementPreviewLight {
|
||||
RequestVerificationHeader(onVerifyClicked = {}, onDismissClicked = {})
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
internal fun PreviewRequestVerificationHeaderDark() {
|
||||
ElementPreviewDark {
|
||||
RequestVerificationHeader(onVerifyClicked = {}, onDismissClicked = {})
|
||||
}
|
||||
}
|
||||
|
||||
private fun RoomListRoomSummary.contentType() = isPlaceholder
|
||||
|
||||
@Preview
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
|
|||
import io.element.android.libraries.dateformatter.api.LastMessageFormatter
|
||||
import io.element.android.libraries.dateformatter.test.FakeLastMessageFormatter
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationFlowState
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||
|
|
@ -35,6 +37,7 @@ import io.element.android.libraries.matrix.test.A_USER_NAME
|
|||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
|
||||
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
|
||||
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
|
|
@ -44,7 +47,8 @@ class RoomListPresenterTests {
|
|||
fun `present - should start with no user and then load user with success`() = runTest {
|
||||
val presenter = RoomListPresenter(
|
||||
FakeMatrixClient(A_SESSION_ID),
|
||||
createDateFormatter()
|
||||
createDateFormatter(),
|
||||
FakeSessionVerificationService(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -68,7 +72,8 @@ class RoomListPresenterTests {
|
|||
userDisplayName = Result.failure(AN_EXCEPTION),
|
||||
userAvatarURLString = Result.failure(AN_EXCEPTION),
|
||||
),
|
||||
createDateFormatter()
|
||||
createDateFormatter(),
|
||||
FakeSessionVerificationService(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -86,7 +91,8 @@ class RoomListPresenterTests {
|
|||
fun `present - should filter room with success`() = runTest {
|
||||
val presenter = RoomListPresenter(
|
||||
FakeMatrixClient(A_SESSION_ID),
|
||||
createDateFormatter()
|
||||
createDateFormatter(),
|
||||
FakeSessionVerificationService(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -108,7 +114,8 @@ class RoomListPresenterTests {
|
|||
sessionId = A_SESSION_ID,
|
||||
roomSummaryDataSource = roomSummaryDataSource
|
||||
),
|
||||
createDateFormatter()
|
||||
createDateFormatter(),
|
||||
FakeSessionVerificationService(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -135,7 +142,8 @@ class RoomListPresenterTests {
|
|||
sessionId = A_SESSION_ID,
|
||||
roomSummaryDataSource = roomSummaryDataSource
|
||||
),
|
||||
createDateFormatter()
|
||||
createDateFormatter(),
|
||||
FakeSessionVerificationService(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -167,7 +175,8 @@ class RoomListPresenterTests {
|
|||
sessionId = A_SESSION_ID,
|
||||
roomSummaryDataSource = roomSummaryDataSource
|
||||
),
|
||||
createDateFormatter()
|
||||
createDateFormatter(),
|
||||
FakeSessionVerificationService(),
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
|
|
@ -202,6 +211,56 @@ class RoomListPresenterTests {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - handle DismissRequestVerificationPrompt`() = runTest {
|
||||
val roomSummaryDataSource = FakeRoomSummaryDataSource()
|
||||
val presenter = RoomListPresenter(
|
||||
FakeMatrixClient(
|
||||
sessionId = A_SESSION_ID,
|
||||
roomSummaryDataSource = roomSummaryDataSource
|
||||
),
|
||||
createDateFormatter(),
|
||||
FakeSessionVerificationService().apply {
|
||||
givenIsReady(true)
|
||||
givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
},
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val eventSink = awaitItem().eventSink
|
||||
Truth.assertThat(awaitItem().displayVerificationPrompt).isTrue()
|
||||
|
||||
eventSink(RoomListEvents.DismissRequestVerificationPrompt)
|
||||
Truth.assertThat(awaitItem().displayVerificationPrompt).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - presentVerificationSuccessfulMessage & ClearVerificationSuccesfulMessage`() = runTest {
|
||||
val roomSummaryDataSource = FakeRoomSummaryDataSource()
|
||||
val presenter = RoomListPresenter(
|
||||
FakeMatrixClient(
|
||||
sessionId = A_SESSION_ID,
|
||||
roomSummaryDataSource = roomSummaryDataSource
|
||||
),
|
||||
createDateFormatter(),
|
||||
FakeSessionVerificationService().apply {
|
||||
givenIsReady(true)
|
||||
givenVerificationFlowState(VerificationFlowState.Finished)
|
||||
},
|
||||
)
|
||||
moleculeFlow(RecompositionClock.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val displayMessageItem = awaitItem()
|
||||
Truth.assertThat(displayMessageItem.presentVerificationSuccessfulMessage).isTrue()
|
||||
displayMessageItem.eventSink(RoomListEvents.ClearSuccessfulVerificationMessage)
|
||||
Truth.assertThat(awaitItem().presentVerificationSuccessfulMessage).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createDateFormatter(): LastMessageFormatter {
|
||||
return FakeLastMessageFormatter().apply {
|
||||
givenFormat(A_FORMATTED_DATE)
|
||||
|
|
@ -221,4 +280,3 @@ private val aRoomListRoomSummary = RoomListRoomSummary(
|
|||
avatarData = AvatarData(id = A_ROOM_ID.value, name = A_ROOM_NAME),
|
||||
isPlaceholder = false,
|
||||
)
|
||||
|
||||
|
|
|
|||
29
features/verifysession/api/build.gradle.kts
Normal file
29
features/verifysession/api/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
1
features/verifysession/impl/.gitignore
vendored
Normal file
1
features/verifysession/impl/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
56
features/verifysession/impl/build.gradle.kts
Normal file
56
features/verifysession/impl/build.gradle.kts
Normal 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)
|
||||
}
|
||||
0
features/verifysession/impl/consumer-rules.pro
Normal file
0
features/verifysession/impl/consumer-rules.pro
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
)
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -29,4 +29,7 @@ java {
|
|||
|
||||
dependencies {
|
||||
implementation(libs.coroutines.core)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.test.truth)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -196,6 +196,14 @@ object ElementTextStyles {
|
|||
textAlign = TextAlign.Start
|
||||
)
|
||||
|
||||
val bodyMD = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontStyle = FontStyle.Normal,
|
||||
lineHeight = 20.sp,
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
|
||||
val footnote = TextStyle(
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
|
|
|
|||
|
|
@ -16,11 +16,15 @@
|
|||
|
||||
package io.element.android.libraries.designsystem.theme.components
|
||||
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.progressSemantics
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProgressIndicatorDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun CircularProgressIndicator(
|
||||
|
|
@ -49,3 +53,18 @@ fun CircularProgressIndicator(
|
|||
strokeWidth = strokeWidth,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ButtonCircularProgressIndicator(
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = MaterialTheme.colorScheme.onPrimary,
|
||||
strokeWidth: Dp = 2.dp,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = modifier
|
||||
.progressSemantics()
|
||||
.size(18.dp),
|
||||
color = color,
|
||||
strokeWidth = strokeWidth,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.api.core.SessionId
|
|||
import io.element.android.libraries.matrix.api.media.MediaResolver
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
|
||||
interface MatrixClient {
|
||||
val sessionId: SessionId
|
||||
|
|
@ -29,6 +30,7 @@ interface MatrixClient {
|
|||
fun startSync()
|
||||
fun stopSync()
|
||||
fun mediaResolver(): MediaResolver
|
||||
fun sessionVerificationService(): SessionVerificationService
|
||||
suspend fun logout()
|
||||
suspend fun loadUserDisplayName(): Result<String>
|
||||
suspend fun loadUserAvatarURLString(): Result<String>
|
||||
|
|
@ -38,4 +40,6 @@ interface MatrixClient {
|
|||
width: Long,
|
||||
height: Long
|
||||
): Result<ByteArray>
|
||||
|
||||
fun onSlidingSyncUpdate()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -23,12 +23,17 @@ import io.element.android.libraries.matrix.api.core.UserId
|
|||
import io.element.android.libraries.matrix.api.media.MediaResolver
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.impl.media.RustMediaResolver
|
||||
import io.element.android.libraries.matrix.impl.room.RustMatrixRoom
|
||||
import io.element.android.libraries.matrix.impl.room.RustRoomSummaryDataSource
|
||||
import io.element.android.libraries.matrix.impl.sync.SlidingSyncObserverProxy
|
||||
import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.matrix.rustcomponents.sdk.Client
|
||||
import org.matrix.rustcomponents.sdk.ClientDelegate
|
||||
|
|
@ -53,6 +58,9 @@ class RustMatrixClient constructor(
|
|||
|
||||
override val sessionId: UserId = UserId(client.userId())
|
||||
|
||||
private val verificationService = RustSessionVerificationService()
|
||||
private var slidingSyncUpdateJob: Job? = null
|
||||
|
||||
private val clientDelegate = object : ClientDelegate {
|
||||
override fun didReceiveAuthError(isSoftLogout: Boolean) {
|
||||
Timber.v("didReceiveAuthError()")
|
||||
|
|
@ -131,6 +139,9 @@ class RustMatrixClient constructor(
|
|||
client.setDelegate(clientDelegate)
|
||||
rustRoomSummaryDataSource.init()
|
||||
slidingSync.setObserver(slidingSyncObserverProxy)
|
||||
slidingSyncUpdateJob = slidingSyncObserverProxy.updateSummaryFlow
|
||||
.onEach { onSlidingSyncUpdate() }
|
||||
.launchIn(coroutineScope)
|
||||
}
|
||||
|
||||
private fun onRestartSync() {
|
||||
|
|
@ -152,6 +163,8 @@ class RustMatrixClient constructor(
|
|||
|
||||
override fun mediaResolver(): MediaResolver = mediaResolver
|
||||
|
||||
override fun sessionVerificationService(): SessionVerificationService = verificationService
|
||||
|
||||
override fun startSync() {
|
||||
if (client.isSoftLogout()) return
|
||||
if (isSyncing.compareAndSet(false, true)) {
|
||||
|
|
@ -166,12 +179,14 @@ class RustMatrixClient constructor(
|
|||
}
|
||||
|
||||
private fun close() {
|
||||
slidingSyncUpdateJob?.cancel()
|
||||
stopSync()
|
||||
slidingSync.setObserver(null)
|
||||
rustRoomSummaryDataSource.close()
|
||||
client.setDelegate(null)
|
||||
visibleRoomsView.destroy()
|
||||
slidingSync.destroy()
|
||||
verificationService.destroy()
|
||||
}
|
||||
|
||||
override suspend fun logout() = withContext(dispatchers.io) {
|
||||
|
|
@ -226,6 +241,16 @@ class RustMatrixClient constructor(
|
|||
}
|
||||
}
|
||||
|
||||
override fun onSlidingSyncUpdate() {
|
||||
if (!verificationService.isReady.value) {
|
||||
try {
|
||||
verificationService.verificationController = client.getSessionVerificationController()
|
||||
} catch (e: Throwable) {
|
||||
Timber.e(e, "Could not start verification service. Will try again on the next sliding sync update.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun File.deleteSessionDirectory(userID: String): Boolean {
|
||||
// Rust sanitises the user ID replacing invalid characters with an _
|
||||
val sanitisedUserID = userID.replace(":", "_")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -36,7 +36,6 @@ class SlidingSyncObserverProxy(
|
|||
val updateSummaryFlow: SharedFlow<UpdateSummary> = updateSummaryMutableFlow.asSharedFlow()
|
||||
|
||||
override fun didReceiveSyncUpdate(summary: UpdateSummary) {
|
||||
if (summary.rooms.isEmpty()) return
|
||||
coroutineScope.launch {
|
||||
updateSummaryMutableFlow.emit(summary)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -22,16 +22,19 @@ import io.element.android.libraries.matrix.api.core.SessionId
|
|||
import io.element.android.libraries.matrix.api.media.MediaResolver
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.test.media.FakeMediaResolver
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
|
||||
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
class FakeMatrixClient(
|
||||
override val sessionId: SessionId = A_SESSION_ID,
|
||||
private val userDisplayName: Result<String> = Result.success(A_USER_NAME),
|
||||
private val userAvatarURLString: Result<String> = Result.success(AN_AVATAR_URL),
|
||||
override val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource()
|
||||
override val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(),
|
||||
private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService()
|
||||
) : MatrixClient {
|
||||
|
||||
private var logoutFailure: Throwable? = null
|
||||
|
|
@ -72,4 +75,8 @@ class FakeMatrixClient(
|
|||
override suspend fun loadMediaThumbnail(url: String, width: Long, height: Long): Result<ByteArray> {
|
||||
return Result.success(ByteArray(0))
|
||||
}
|
||||
|
||||
override fun sessionVerificationService(): SessionVerificationService = sessionVerificationService
|
||||
|
||||
override fun onSlidingSyncUpdate() {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -19,4 +19,29 @@
|
|||
<string name="search_for_someone">Search for someone</string>
|
||||
<string name="new_room">New room</string>
|
||||
|
||||
<string name="verification_title_initial">Open an existing session</string>
|
||||
<string name="verification_title_waiting">Waiting to accept request</string>
|
||||
<string name="verification_title_canceled">Verification cancelled</string>
|
||||
<string name="verification_title_verifying">Compare emojis</string>
|
||||
|
||||
<string name="verification_subtitle_initial">Prove it\'s you in order to access your encrypted message history.</string>
|
||||
<string name="verification_subtitle_waiting">Accept the request to start the verification process in your other session to continue.</string>
|
||||
<string name="verification_subtitle_canceled">Something doesn\'t seem right. Either the request timed out or the request was denied.</string>
|
||||
<string name="verification_subtitle_verifying">Confirm that the emojis below match those shown on your other session.</string>
|
||||
|
||||
<string name="verification_positive_button_initial">I am ready</string>
|
||||
<string name="verification_positive_button_canceled">Retry verification</string>
|
||||
<string name="verification_positive_button_verifying_start">They match</string>
|
||||
<string name="verification_positive_button_verifying_ongoing">Waiting to match</string>
|
||||
|
||||
<string name="verification_negative_button_initial">@string/action_cancel</string>
|
||||
<string name="verification_negative_button_canceled">@string/action_cancel</string>
|
||||
<string name="verification_negative_button_verifying">They don\'t match</string>
|
||||
|
||||
<string name="session_verification_banner_title">Access your message history</string>
|
||||
<string name="session_verification_banner_message">Looks like you\'re using a new device. Verify it\'s you to access your encrypted messages.</string>
|
||||
<string name="session_verification_start">Continue</string>
|
||||
|
||||
<string name="verification_conclusion_ok_self_notice_title">Verification complete</string>
|
||||
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ fun DependencyHandlerScope.allFeaturesApi() {
|
|||
implementation(project(":features:rageshake:api"))
|
||||
implementation(project(":features:preferences:api"))
|
||||
implementation(project(":features:createroom:api"))
|
||||
implementation(project(":features:verifysession:api"))
|
||||
}
|
||||
|
||||
fun DependencyHandlerScope.allFeaturesImpl() {
|
||||
|
|
@ -87,4 +88,5 @@ fun DependencyHandlerScope.allFeaturesImpl() {
|
|||
implementation(project(":features:rageshake:impl"))
|
||||
implementation(project(":features:preferences:impl"))
|
||||
implementation(project(":features:createroom:impl"))
|
||||
implementation(project(":features:verifysession:impl"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@ class RoomListScreen(
|
|||
private val timeZone = TimeZone.currentSystemDefault()
|
||||
private val dateTimeProvider = LocalDateTimeProvider(clock, timeZone)
|
||||
private val dateFormatters = DateFormatters(locale, clock, timeZone)
|
||||
private val presenter = RoomListPresenter(matrixClient, DefaultLastMessageFormatter(dateTimeProvider, dateFormatters))
|
||||
private val sessionVerificationService = matrixClient.sessionVerificationService()
|
||||
private val presenter = RoomListPresenter(matrixClient, DefaultLastMessageFormatter(dateTimeProvider, dateFormatters), sessionVerificationService)
|
||||
|
||||
@Composable
|
||||
fun Content(modifier: Modifier = Modifier) {
|
||||
|
|
|
|||
|
|
@ -85,3 +85,5 @@ include(":features:login:api")
|
|||
include(":features:login:impl")
|
||||
include(":features:createroom:api")
|
||||
include(":features:createroom:impl")
|
||||
include(":features:verifysession:api")
|
||||
include(":features:verifysession:impl")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:474c799a08ccba7c8b4d9ba3d120b103d0e8bb9654bb4da576d4eee579e5d517
|
||||
size 28222
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c705a9c97a0bc5a2987cef4db6fd9b79de1cf1f5405035c6371615fb72fc5f36
|
||||
size 27715
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f653439749b16d9f683b63984855e2dad576e5d567eeed792f28e9484b12b67f
|
||||
size 60599
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0fc272268179409483fc5fad89aa00714a6811c63e478b5c696d4ab0338ce6bf
|
||||
size 37781
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d88db44fb66340043d83e256b1eb7655e66af5e11daa55bd1336b4ff6e57883d
|
||||
size 59472
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:43b69859fa3ee38d2b7f7415b87738db65dc6dac3d2fabddc1f1346b0b64932b
|
||||
size 37329
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3d474dbae506ecab03cf32414bd4af4c16c934d49be170c6572f2492c6362350
|
||||
size 28356
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:745725187e65e850f04937783bbf99c3cc805dd3a9ce7580ac479c9ab2b0f9e1
|
||||
size 27829
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:07e72c29b3a928eaa86ec7c97cb680cf2eaacebf6512390eb9dda47bdc2a9c7e
|
||||
size 29249
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:76b4ac8395ac876c4ff979bd92279b9735fae581987326c6ab929e8578335c5a
|
||||
size 26657
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ca4957bb6995efbf398bf211da38cafb6b2c1693167f9c940d6a31343bfcf127
|
||||
size 61454
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7590a2604bb93de8a416e7e45b6c410c27fd83481cfb08f3f41e820d22fc5582
|
||||
size 62046
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4861b0c969a8af115ad2638b54f5893e813633a61bebb8bccafa01cfda320ea9
|
||||
size 31937
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4ee6ee256e59932e2d6aa29c393ec30499da06c6759ed33e8298838618bbfb9f
|
||||
size 28465
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:586954a4fae4405f47a02140ec0ea9c89b2f6becc1b9b852d0a8fa28e10bf089
|
||||
size 25959
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:083f2c5ba49a4579015c38ffa2e0c2dd4ed04f389faa4c90cf6afa6438e08ca7
|
||||
size 59174
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ed5e914779a3dcfbed0af73cace4e9141259932a892ae57ee902aec4860bd879
|
||||
size 59628
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2c8534d5464f3ee2551df199d18af1589f6c1e0c66292756be1e1af9deee1b17
|
||||
size 31580
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c7513e7f34cb817fee0f74ca4a00204cd5d8b0928f80afdf487a9cc8e43a1fc8
|
||||
size 29289
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:280505afce4b14d28a13a2a4e16dc1bc0a5e9e350c9937d185fe19746ba2a415
|
||||
size 26658
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0e9a966e2f3161eac1aedf146d033a47a9724b562dd07bffc3bb88b1e5ed00b2
|
||||
size 61502
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bd3546ed310fb679f6226af97c5832f15a162906d21c15805cd19ecf5690a945
|
||||
size 62094
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2ea9c30f0f450962a591ad4e13ea19a2e8b13f863359c20d1edd3fec6675811d
|
||||
size 31902
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:42ab717a579388fe13d1b5438c82348527f48f8387ee4336cf4d5483e1c1d4af
|
||||
size 28512
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4fe51076b770fabdda09687fed36a500bf05295f3cc599fc524cc87831a65a83
|
||||
size 25949
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e8136c7d1fe030e3575f7929c20ad22dd7d002143a57d934b6252d9eb0845f55
|
||||
size 59181
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:88cc80bb7272ef9fa012c8f394f8136f228fcf5903e65e7fb5ba03679b1ed0cd
|
||||
size 59625
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ae2530c339d94a6a615df644950e54d3b442e09e0721d036b26386bfa75525fe
|
||||
size 31580
|
||||
Loading…
Add table
Add a link
Reference in a new issue