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