Merge branch 'develop' into feature/bma/push

This commit is contained in:
Benoit Marty
2023-04-06 09:13:24 +02:00
committed by GitHub
31 changed files with 502 additions and 49 deletions

View File

@@ -32,6 +32,7 @@ import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.replace
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
@@ -199,7 +200,16 @@ class LoggedInFlowNode @AssistedInject constructor(
.build() .build()
} }
NavTarget.CreateRoom -> { NavTarget.CreateRoom -> {
createRoomEntryPoint.createNode(this, buildContext) val callback = object : CreateRoomEntryPoint.Callback {
override fun onOpenRoom(roomId: RoomId) {
backstack.replace(NavTarget.Room(roomId))
}
}
createRoomEntryPoint
.nodeBuilder(this, buildContext)
.callback(callback)
.build()
} }
NavTarget.VerifySession -> { NavTarget.VerifySession -> {
verifySessionEntryPoint.createNode(this, buildContext) verifySessionEntryPoint.createNode(this, buildContext)

View File

@@ -231,6 +231,7 @@ koverMerged {
overrideClassFilter { overrideClassFilter {
includes += "*Presenter" includes += "*Presenter"
excludes += "*TemplatePresenter" excludes += "*TemplatePresenter"
excludes += "*Fake*Presenter"
excludes += "io.element.android.appnav.loggedin.LoggedInPresenter$*" excludes += "io.element.android.appnav.loggedin.LoggedInPresenter$*"
} }
bound { bound {

1
changelog.d/96.feature Normal file
View File

@@ -0,0 +1 @@
[Create and join rooms] Show or create direct message room

View File

@@ -24,4 +24,5 @@ android {
dependencies { dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
} }

View File

@@ -16,6 +16,21 @@
package io.element.android.features.createroom.api package io.element.android.features.createroom.api
import io.element.android.libraries.architecture.SimpleFeatureEntryPoint import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
interface CreateRoomEntryPoint : SimpleFeatureEntryPoint interface CreateRoomEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder {
fun callback(callback: Callback): NodeBuilder
fun build(): Node
}
interface Callback : Plugin {
fun onOpenRoom(roomId: RoomId)
}
}

View File

@@ -23,17 +23,20 @@ import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.createroom.impl.addpeople.AddPeopleNode import io.element.android.features.createroom.impl.addpeople.AddPeopleNode
import io.element.android.features.createroom.impl.root.CreateRoomRootNode import io.element.android.features.createroom.impl.root.CreateRoomRootNode
import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@@ -64,6 +67,10 @@ class CreateRoomFlowNode @AssistedInject constructor(
override fun onCreateNewRoom() { override fun onCreateNewRoom() {
backstack.push(NavTarget.NewRoom) backstack.push(NavTarget.NewRoom)
} }
override fun onOpenRoom(roomId: RoomId) {
plugins<CreateRoomEntryPoint.Callback>().forEach { it.onOpenRoom(roomId) }
}
} }
createNode<CreateRoomRootNode>(buildContext, plugins = listOf(callback)) createNode<CreateRoomRootNode>(buildContext, plugins = listOf(callback))
} }

View File

@@ -18,6 +18,7 @@ package io.element.android.features.createroom.impl
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.createroom.api.CreateRoomEntryPoint import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
@@ -26,7 +27,21 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultCreateRoomEntryPoint @Inject constructor() : CreateRoomEntryPoint { class DefaultCreateRoomEntryPoint @Inject constructor() : CreateRoomEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
return parentNode.createNode<CreateRoomFlowNode>(buildContext) override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): CreateRoomEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : CreateRoomEntryPoint.NodeBuilder {
override fun callback(callback: CreateRoomEntryPoint.Callback): CreateRoomEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node {
return parentNode.createNode<CreateRoomFlowNode>(buildContext, plugins)
}
}
} }
} }

View File

@@ -19,6 +19,7 @@ package io.element.android.features.createroom.impl.root
import io.element.android.libraries.matrix.ui.model.MatrixUser import io.element.android.libraries.matrix.ui.model.MatrixUser
sealed interface CreateRoomRootEvents { sealed interface CreateRoomRootEvents {
data class StartDM(val matrixUser: MatrixUser) : CreateRoomRootEvents
object InvitePeople : CreateRoomRootEvents object InvitePeople : CreateRoomRootEvents
data class StartDM(val matrixUser: MatrixUser) : CreateRoomRootEvents
object CancelStartDM : CreateRoomRootEvents
} }

View File

@@ -16,7 +16,6 @@
package io.element.android.features.createroom.impl.root package io.element.android.features.createroom.impl.root
import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
@@ -27,7 +26,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import kotlinx.parcelize.Parcelize import io.element.android.libraries.matrix.api.core.RoomId
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
class CreateRoomRootNode @AssistedInject constructor( class CreateRoomRootNode @AssistedInject constructor(
@@ -38,15 +37,17 @@ class CreateRoomRootNode @AssistedInject constructor(
interface Callback : Plugin { interface Callback : Plugin {
fun onCreateNewRoom() fun onCreateNewRoom()
fun onOpenRoom(roomId: RoomId)
} }
private fun onCreateNewRoom() { private val callback = object : Callback {
plugins<Callback>().forEach { it.onCreateNewRoom() } override fun onCreateNewRoom() {
} plugins<Callback>().forEach { it.onCreateNewRoom() }
}
sealed interface NavTarget : Parcelable { override fun onOpenRoom(roomId: RoomId) {
@Parcelize plugins<Callback>().forEach { it.onOpenRoom(roomId) }
object Root : NavTarget }
} }
@Composable @Composable
@@ -56,7 +57,8 @@ class CreateRoomRootNode @AssistedInject constructor(
state = state, state = state,
modifier = modifier, modifier = modifier,
onClosePressed = this::navigateUp, onClosePressed = this::navigateUp,
onNewRoomClicked = this::onCreateNewRoom, onNewRoomClicked = callback::onCreateNewRoom,
onOpenDM = callback::onOpenRoom,
) )
} }
} }

View File

@@ -17,19 +17,29 @@
package io.element.android.features.createroom.impl.root package io.element.android.features.createroom.impl.root
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import io.element.android.features.userlist.api.SelectionMode import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.features.userlist.api.MatrixUserDataSource import io.element.android.features.userlist.api.MatrixUserDataSource
import io.element.android.features.userlist.api.SelectionMode
import io.element.android.features.userlist.api.UserListPresenter import io.element.android.features.userlist.api.UserListPresenter
import io.element.android.features.userlist.api.UserListPresenterArgs import io.element.android.features.userlist.api.UserListPresenterArgs
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute
import io.element.android.libraries.matrix.api.MatrixClient
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
import timber.log.Timber import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Named import javax.inject.Named
class CreateRoomRootPresenter @Inject constructor( class CreateRoomRootPresenter @Inject constructor(
private val presenterFactory: UserListPresenter.Factory, private val presenterFactory: UserListPresenter.Factory,
@Named("AllUsers") private val matrixUserDataSource: MatrixUserDataSource, @Named("AllUsers") private val matrixUserDataSource: MatrixUserDataSource,
private val matrixClient: MatrixClient,
) : Presenter<CreateRoomRootState> { ) : Presenter<CreateRoomRootState> {
private val presenter by lazy { private val presenter by lazy {
@@ -43,20 +53,37 @@ class CreateRoomRootPresenter @Inject constructor(
override fun present(): CreateRoomRootState { override fun present(): CreateRoomRootState {
val userListState = presenter.present() val userListState = presenter.present()
val localCoroutineScope = rememberCoroutineScope()
val startDmAction: MutableState<Async<RoomId>> = remember { mutableStateOf(Async.Uninitialized) }
fun startDm(matrixUser: MatrixUser) {
startDmAction.value = Async.Uninitialized
val existingDM = matrixClient.findDM(matrixUser.id)
if (existingDM == null) {
localCoroutineScope.createDM(matrixUser, startDmAction)
} else {
startDmAction.value = Async.Success(existingDM.roomId)
}
}
fun handleEvents(event: CreateRoomRootEvents) { fun handleEvents(event: CreateRoomRootEvents) {
when (event) { when (event) {
is CreateRoomRootEvents.StartDM -> handleStartDM(event.matrixUser) is CreateRoomRootEvents.StartDM -> startDm(event.matrixUser)
CreateRoomRootEvents.CancelStartDM -> startDmAction.value = Async.Uninitialized
CreateRoomRootEvents.InvitePeople -> Unit // Todo Handle invite people action CreateRoomRootEvents.InvitePeople -> Unit // Todo Handle invite people action
} }
} }
return CreateRoomRootState( return CreateRoomRootState(
userListState = userListState, userListState = userListState,
startDmAction = startDmAction.value,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }
private fun handleStartDM(matrixUser: MatrixUser) { private fun CoroutineScope.createDM(user: MatrixUser, startDmAction: MutableState<Async<RoomId>>) = launch {
Timber.d("handleStartDM: $matrixUser") // Todo handle start DM action suspend {
matrixClient.createDM(user.id).getOrThrow()
}.execute(startDmAction)
} }
} }

View File

@@ -17,8 +17,11 @@
package io.element.android.features.createroom.impl.root package io.element.android.features.createroom.impl.root
import io.element.android.features.userlist.api.UserListState import io.element.android.features.userlist.api.UserListState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId
data class CreateRoomRootState( data class CreateRoomRootState(
val userListState: UserListState, val userListState: UserListState,
val startDmAction: Async<RoomId>,
val eventSink: (CreateRoomRootEvents) -> Unit, val eventSink: (CreateRoomRootEvents) -> Unit,
) )

View File

@@ -18,15 +18,41 @@ package io.element.android.features.createroom.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.userlist.api.aUserListState import io.element.android.features.userlist.api.aUserListState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import kotlinx.collections.immutable.persistentListOf
open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRootState> { open class CreateRoomRootStateProvider : PreviewParameterProvider<CreateRoomRootState> {
override val values: Sequence<CreateRoomRootState> override val values: Sequence<CreateRoomRootState>
get() = sequenceOf( get() = sequenceOf(
aCreateRoomRootState(), aCreateRoomRootState(),
aCreateRoomRootState().copy(
startDmAction = Async.Loading(),
userListState = aMatrixUser().let {
aUserListState().copy(
searchQuery = it.id.value,
searchResults = persistentListOf(it),
selectedUsers = persistentListOf(it),
isSearchActive = true,
)
}
),
aCreateRoomRootState().copy(
startDmAction = Async.Failure(Throwable()),
userListState = aMatrixUser().let {
aUserListState().copy(
searchQuery = it.id.value,
searchResults = persistentListOf(it),
selectedUsers = persistentListOf(it),
isSearchActive = true,
)
}
),
) )
} }
fun aCreateRoomRootState() = CreateRoomRootState( fun aCreateRoomRootState() = CreateRoomRootState(
eventSink = {}, eventSink = {},
userListState = aUserListState(), startDmAction = Async.Uninitialized,
userListState = aUserListState(),
) )

View File

@@ -16,6 +16,7 @@
package io.element.android.features.createroom.impl.root package io.element.android.features.createroom.impl.root
import android.widget.Toast
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -28,17 +29,22 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
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.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import io.element.android.features.userlist.api.UserListView
import io.element.android.features.createroom.impl.R import io.element.android.features.createroom.impl.R
import io.element.android.features.userlist.api.UserListView
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
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.CenterAlignedTopAppBar import io.element.android.libraries.designsystem.theme.components.CenterAlignedTopAppBar
@@ -46,6 +52,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.IconButton
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.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.designsystem.R as DrawableR import io.element.android.libraries.designsystem.R as DrawableR
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.R as StringR
@@ -56,7 +63,14 @@ fun CreateRoomRootView(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClosePressed: () -> Unit = {}, onClosePressed: () -> Unit = {},
onNewRoomClicked: () -> Unit = {}, onNewRoomClicked: () -> Unit = {},
onOpenDM: (RoomId) -> Unit = {},
) { ) {
if (state.startDmAction is Async.Success) {
LaunchedEffect(state.startDmAction) {
onOpenDM(state.startDmAction.state)
}
}
Scaffold( Scaffold(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
topBar = { topBar = {
@@ -69,10 +83,16 @@ fun CreateRoomRootView(
modifier = Modifier.padding(paddingValues), modifier = Modifier.padding(paddingValues),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
val context = LocalContext.current
UserListView( UserListView(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
state = state.userListState, state = state.userListState,
onUserSelected = { state.eventSink.invoke(CreateRoomRootEvents.StartDM(it)) }, onUserSelected = {
// Fixme disabled DM creation since it can break the account data which is not correctly synced
// uncomment to enable it again or move behind a feature flag
Toast.makeText(context, "Create DM feature is disabled.", Toast.LENGTH_SHORT).show()
// state.eventSink(CreateRoomRootEvents.StartDM(it))
},
) )
if (!state.userListState.isSearchActive) { if (!state.userListState.isSearchActive) {
@@ -83,6 +103,25 @@ fun CreateRoomRootView(
} }
} }
} }
when (state.startDmAction) {
is Async.Loading -> {
ProgressDialog(text = stringResource(id = StringR.string.common_creating_room))
}
is Async.Failure -> {
RetryDialog(
content = stringResource(id = StringR.string.screen_start_chat_error_starting_chat),
onDismiss = { state.eventSink(CreateRoomRootEvents.CancelStartDM) },
onRetry = {
state.userListState.selectedUsers.firstOrNull()
?.let { state.eventSink(CreateRoomRootEvents.StartDM(it)) }
// Cancel start DM if there is no more selected user (should not happen)
?: state.eventSink(CreateRoomRootEvents.CancelStartDM)
},
)
}
else -> Unit
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)

View File

@@ -22,10 +22,8 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.userlist.api.MatrixUserDataSource
import io.element.android.features.userlist.api.UserListPresenterArgs
import io.element.android.features.userlist.impl.DefaultUserListPresenter
import io.element.android.features.userlist.test.FakeMatrixUserDataSource import io.element.android.features.userlist.test.FakeMatrixUserDataSource
import io.element.android.features.userlist.test.FakeUserListPresenterFactory
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
@@ -37,11 +35,7 @@ class AddPeoplePresenterTests {
@Before @Before
fun setup() { fun setup() {
val userListFactory = object : DefaultUserListPresenter.DefaultUserListFactory { presenter = AddPeoplePresenter(FakeUserListPresenterFactory(), FakeMatrixUserDataSource())
override fun create(args: UserListPresenterArgs, dataSource: MatrixUserDataSource) = DefaultUserListPresenter(args, dataSource)
}
val dataSource = FakeMatrixUserDataSource()
presenter = AddPeoplePresenter(userListFactory, dataSource)
} }
@Test @Test

View File

@@ -22,12 +22,18 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.userlist.api.MatrixUserDataSource import io.element.android.features.userlist.api.aUserListState
import io.element.android.features.userlist.api.UserListPresenterArgs
import io.element.android.features.userlist.impl.DefaultUserListPresenter
import io.element.android.features.userlist.test.FakeMatrixUserDataSource import io.element.android.features.userlist.test.FakeMatrixUserDataSource
import io.element.android.features.userlist.test.FakeUserListPresenter
import io.element.android.features.userlist.test.FakeUserListPresenterFactory
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.ui.model.MatrixUser import io.element.android.libraries.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
@@ -37,14 +43,15 @@ class CreateRoomRootPresenterTests {
private lateinit var userListDataSource: FakeMatrixUserDataSource private lateinit var userListDataSource: FakeMatrixUserDataSource
private lateinit var presenter: CreateRoomRootPresenter private lateinit var presenter: CreateRoomRootPresenter
private lateinit var fakeUserListPresenter: FakeUserListPresenter
private lateinit var fakeMatrixClient: FakeMatrixClient
@Before @Before
fun setup() { fun setup() {
val userListPresenter = object : DefaultUserListPresenter.DefaultUserListFactory { fakeUserListPresenter = FakeUserListPresenter()
override fun create(args: UserListPresenterArgs, dataSource: MatrixUserDataSource) = DefaultUserListPresenter(args, dataSource) fakeMatrixClient = FakeMatrixClient()
}
userListDataSource = FakeMatrixUserDataSource() userListDataSource = FakeMatrixUserDataSource()
presenter = CreateRoomRootPresenter(userListPresenter, userListDataSource) presenter = CreateRoomRootPresenter(FakeUserListPresenterFactory(fakeUserListPresenter), userListDataSource, fakeMatrixClient)
} }
@Test @Test
@@ -68,13 +75,82 @@ class CreateRoomRootPresenterTests {
} }
@Test @Test
fun `present - trigger start DM action`() = runTest { fun `present - trigger create DM action`() = runTest {
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
val matrixUser = MatrixUser(UserId("@name:matrix.org")) val matrixUser = MatrixUser(UserId("@name:matrix.org"))
val createDmResult = Result.success(RoomId("!createDmResult"))
fakeMatrixClient.givenFindDmResult(null)
fakeMatrixClient.givenCreateDmResult(createDmResult)
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser)) initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
val stateAfterStartDM = awaitItem()
assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Success::class.java)
assertThat(stateAfterStartDM.startDmAction.dataOrNull()).isEqualTo(createDmResult.getOrNull())
}
}
@Test
fun `present - trigger retrieve DM action`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val matrixUser = MatrixUser(UserId("@name:matrix.org"))
val fakeDmResult = FakeMatrixRoom(RoomId("!fakeDmResult"))
fakeMatrixClient.givenFindDmResult(fakeDmResult)
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
val stateAfterStartDM = awaitItem()
assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Success::class.java)
assertThat(stateAfterStartDM.startDmAction.dataOrNull()).isEqualTo(fakeDmResult.roomId)
}
}
@Test
fun `present - trigger retry create DM action`() = runTest {
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val matrixUser = MatrixUser(UserId("@name:matrix.org"))
val createDmResult = Result.success(RoomId("!createDmResult"))
fakeUserListPresenter.givenState(aUserListState().copy(selectedUsers = persistentListOf(matrixUser)))
fakeMatrixClient.givenFindDmResult(null)
fakeMatrixClient.givenCreateDmError(A_THROWABLE)
fakeMatrixClient.givenCreateDmResult(createDmResult)
// Failure
initialState.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
val stateAfterStartDM = awaitItem()
assertThat(stateAfterStartDM.startDmAction).isInstanceOf(Async.Failure::class.java)
// Cancel
stateAfterStartDM.eventSink(CreateRoomRootEvents.CancelStartDM)
val stateAfterCancel = awaitItem()
assertThat(stateAfterCancel.startDmAction).isInstanceOf(Async.Uninitialized::class.java)
// Failure
stateAfterCancel.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
val stateAfterSecondAttempt = awaitItem()
assertThat(stateAfterSecondAttempt.startDmAction).isInstanceOf(Async.Failure::class.java)
// Retry with success
fakeMatrixClient.givenCreateDmError(null)
stateAfterSecondAttempt.eventSink(CreateRoomRootEvents.StartDM(matrixUser))
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Uninitialized::class.java)
assertThat(awaitItem().startDmAction).isInstanceOf(Async.Loading::class.java)
val stateAfterRetryStartDM = awaitItem()
assertThat(stateAfterRetryStartDM.startDmAction).isInstanceOf(Async.Success::class.java)
assertThat(stateAfterRetryStartDM.startDmAction.dataOrNull()).isEqualTo(createDmResult.getOrNull())
} }
} }
} }

View File

@@ -14,10 +14,8 @@
* limitations under the License. * limitations under the License.
*/ */
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
@Suppress("DSL_SCOPE_VIOLATION")
plugins { plugins {
id("io.element.android-library") id("io.element.android-compose-library")
} }
android { android {
@@ -25,6 +23,7 @@ android {
} }
dependencies { dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
api(projects.features.userlist.api) api(projects.features.userlist.api)

View File

@@ -0,0 +1,36 @@
/*
* 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.userlist.test
import androidx.compose.runtime.Composable
import io.element.android.features.userlist.api.UserListPresenter
import io.element.android.features.userlist.api.UserListState
import io.element.android.features.userlist.api.aUserListState
class FakeUserListPresenter : UserListPresenter {
private var state = aUserListState()
fun givenState(state: UserListState) {
this.state = state
}
@Composable
override fun present(): UserListState {
return state
}
}

View File

@@ -0,0 +1,28 @@
/*
* 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.userlist.test
import io.element.android.features.userlist.api.MatrixUserDataSource
import io.element.android.features.userlist.api.UserListPresenter
import io.element.android.features.userlist.api.UserListPresenterArgs
class FakeUserListPresenterFactory(
private val fakeUserListPresenter: FakeUserListPresenter = FakeUserListPresenter()
) : UserListPresenter.Factory {
override fun create(args: UserListPresenterArgs, matrixUserDataSource: MatrixUserDataSource): UserListPresenter = fakeUserListPresenter
}

View File

@@ -0,0 +1,97 @@
/*
* 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.designsystem.components.dialogs
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.R as StringR
@Composable
fun RetryDialog(
content: String,
modifier: Modifier = Modifier,
title: String = RetryDialogDefaults.title,
retryText: String = RetryDialogDefaults.retryText,
dismissText: String = RetryDialogDefaults.dismissText,
onRetry: () -> Unit = {},
onDismiss: () -> Unit = {},
shape: Shape = AlertDialogDefaults.shape,
containerColor: Color = AlertDialogDefaults.containerColor,
iconContentColor: Color = AlertDialogDefaults.iconContentColor,
titleContentColor: Color = AlertDialogDefaults.titleContentColor,
textContentColor: Color = AlertDialogDefaults.textContentColor,
tonalElevation: Dp = AlertDialogDefaults.TonalElevation,
) {
AlertDialog(
modifier = modifier,
onDismissRequest = onDismiss,
title = {
Text(title)
},
text = {
Text(content)
},
confirmButton = {
TextButton(onClick = onRetry) {
Text(retryText)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(dismissText)
}
},
shape = shape,
containerColor = containerColor,
iconContentColor = iconContentColor,
titleContentColor = titleContentColor,
textContentColor = textContentColor,
tonalElevation = tonalElevation,
)
}
object RetryDialogDefaults {
val title: String @Composable get() = stringResource(id = StringR.string.dialog_title_error)
val retryText: String @Composable get() = stringResource(id = StringR.string.action_retry)
val dismissText: String @Composable get() = stringResource(id = StringR.string.action_cancel)
}
@Preview
@Composable
internal fun RetryDialogLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun RetryDialogDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
RetryDialog(
content = "Content",
)
}

View File

@@ -18,6 +18,7 @@ package io.element.android.libraries.matrix.api
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
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.notification.NotificationService import io.element.android.libraries.matrix.api.notification.NotificationService
import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.pusher.PushersService
@@ -31,6 +32,8 @@ interface MatrixClient : Closeable {
val sessionId: SessionId val sessionId: SessionId
val roomSummaryDataSource: RoomSummaryDataSource val roomSummaryDataSource: RoomSummaryDataSource
fun getRoom(roomId: RoomId): MatrixRoom? fun getRoom(roomId: RoomId): MatrixRoom?
suspend fun createDM(userId: UserId): Result<RoomId>
fun findDM(userId: UserId): MatrixRoom?
fun startSync() fun startSync()
fun stopSync() fun stopSync()
fun mediaResolver(): MediaResolver fun mediaResolver(): MediaResolver

View File

@@ -42,7 +42,10 @@ 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
import org.matrix.rustcomponents.sdk.CreateRoomParameters
import org.matrix.rustcomponents.sdk.RequiredState import org.matrix.rustcomponents.sdk.RequiredState
import org.matrix.rustcomponents.sdk.RoomPreset
import org.matrix.rustcomponents.sdk.RoomVisibility
import org.matrix.rustcomponents.sdk.SlidingSyncListBuilder import org.matrix.rustcomponents.sdk.SlidingSyncListBuilder
import org.matrix.rustcomponents.sdk.SlidingSyncMode import org.matrix.rustcomponents.sdk.SlidingSyncMode
import org.matrix.rustcomponents.sdk.SlidingSyncRequestListFilters import org.matrix.rustcomponents.sdk.SlidingSyncRequestListFilters
@@ -167,6 +170,30 @@ class RustMatrixClient constructor(
) )
} }
override fun findDM(userId: UserId): MatrixRoom? {
val roomId = client.getDmRoom(userId.value)?.use { RoomId(it.id()) }
return roomId?.let { getRoom(it) }
}
override suspend fun createDM(userId: UserId): Result<RoomId> =
withContext(dispatchers.io) {
runCatching {
val roomId = client.createRoom(
CreateRoomParameters(
name = null,
topic = null,
isEncrypted = true,
isDirect = true,
visibility = RoomVisibility.PRIVATE,
preset = RoomPreset.TRUSTED_PRIVATE_CHAT,
invite = listOf(userId.value),
avatar = null,
)
)
RoomId(roomId)
}
}
override fun mediaResolver(): MediaResolver = mediaResolver override fun mediaResolver(): MediaResolver = mediaResolver
override fun sessionVerificationService(): SessionVerificationService = verificationService override fun sessionVerificationService(): SessionVerificationService = verificationService

View File

@@ -19,6 +19,7 @@ package io.element.android.libraries.matrix.test
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
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.notification.NotificationService import io.element.android.libraries.matrix.api.notification.NotificationService
import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.pusher.PushersService
@@ -44,12 +45,25 @@ class FakeMatrixClient(
private val notificationService: FakeNotificationService = FakeNotificationService(), private val notificationService: FakeNotificationService = FakeNotificationService(),
) : MatrixClient { ) : MatrixClient {
private var createDmResult: Result<RoomId> = Result.success(A_ROOM_ID)
private var createDmFailure: Throwable? = null
private var findDmResult: MatrixRoom? = FakeMatrixRoom()
private var logoutFailure: Throwable? = null private var logoutFailure: Throwable? = null
override fun getRoom(roomId: RoomId): MatrixRoom? { override fun getRoom(roomId: RoomId): MatrixRoom? {
return FakeMatrixRoom(roomId) return FakeMatrixRoom(roomId)
} }
override suspend fun createDM(userId: UserId): Result<RoomId> {
delay(100)
createDmFailure?.let { throw it }
return createDmResult
}
override fun findDM(userId: UserId): MatrixRoom? {
return findDmResult
}
override fun startSync() = Unit override fun startSync() = Unit
override fun stopSync() = Unit override fun stopSync() = Unit
@@ -58,10 +72,6 @@ class FakeMatrixClient(
return FakeMediaResolver() return FakeMediaResolver()
} }
fun givenLogoutError(failure: Throwable) {
logoutFailure = failure
}
override suspend fun logout() { override suspend fun logout() {
delay(100) delay(100)
logoutFailure?.let { throw it } logoutFailure?.let { throw it }
@@ -96,4 +106,22 @@ class FakeMatrixClient(
override fun roomMembershipObserver(): RoomMembershipObserver { override fun roomMembershipObserver(): RoomMembershipObserver {
return RoomMembershipObserver(A_SESSION_ID) return RoomMembershipObserver(A_SESSION_ID)
} }
// Mocks
fun givenLogoutError(failure: Throwable?) {
logoutFailure = failure
}
fun givenCreateDmResult(result: Result<RoomId>) {
createDmResult = result
}
fun givenCreateDmError(failure: Throwable?) {
createDmFailure = failure
}
fun givenFindDmResult(result: MatrixRoom?) {
findDmResult = result
}
} }

View File

@@ -1,2 +0,0 @@
configurations.maybeCreate("default")
artifacts.add("default", file('matrix-rust-sdk.aar'))

View File

@@ -0,0 +1,2 @@
configurations.maybeCreate("default")
artifacts.add("default", file("matrix-rust-sdk.aar"))

View File

@@ -51,7 +51,6 @@ include(":appnav")
include(":tests:uitests") include(":tests:uitests")
include(":anvilannotations") include(":anvilannotations")
include(":anvilcodegen") include(":anvilcodegen")
include(":libraries:rustsdk")
include(":samples:minimal") include(":samples:minimal")