Start DM - display a local room before creating the real one

Add CreateLocalRoomTask interface and remove DI annotations
This commit is contained in:
Florian Renaud 2022-03-17 10:37:04 +01:00
parent 26d1a12b74
commit 10d683ad5d
12 changed files with 352 additions and 37 deletions

View file

@ -40,6 +40,13 @@ interface RoomService {
*/
suspend fun createRoom(createRoomParams: CreateRoomParams): String
/**
* Create a room locally.
* This room will not be synchronized with the server and will not come back from the sync, so all the events related to this room will be generated
* locally.
*/
suspend fun createLocalRoom(createRoomParams: CreateRoomParams): String
/**
* Create a direct room asynchronously. This is a facility method to create a direct room with the necessary parameters.
*/

View file

@ -0,0 +1,28 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.api.session.room.model.localecho
import java.util.UUID
object RoomLocalEcho {
private const val PREFIX = "!local."
fun isLocalEchoId(roomId: String) = roomId.startsWith(PREFIX)
fun createLocalEchoId() = "${PREFIX}${UUID.randomUUID()}"
}

View file

@ -43,6 +43,7 @@ import org.matrix.android.sdk.internal.database.model.RoomMemberSummaryEntityFie
import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.session.room.alias.DeleteRoomAliasTask
import org.matrix.android.sdk.internal.session.room.alias.GetRoomIdByAliasTask
import org.matrix.android.sdk.internal.session.room.create.CreateLocalRoomTask
import org.matrix.android.sdk.internal.session.room.create.CreateRoomTask
import org.matrix.android.sdk.internal.session.room.membership.RoomChangeMembershipStateDataSource
import org.matrix.android.sdk.internal.session.room.membership.RoomMemberHelper
@ -60,6 +61,7 @@ import javax.inject.Inject
internal class DefaultRoomService @Inject constructor(
@SessionDatabase private val monarchy: Monarchy,
private val createRoomTask: CreateRoomTask,
private val createLocalRoomTask: CreateLocalRoomTask,
private val joinRoomTask: JoinRoomTask,
private val markAllRoomsReadTask: MarkAllRoomsReadTask,
private val updateBreadcrumbsTask: UpdateBreadcrumbsTask,
@ -78,6 +80,10 @@ internal class DefaultRoomService @Inject constructor(
return createRoomTask.executeRetry(createRoomParams, 3)
}
override suspend fun createLocalRoom(createRoomParams: CreateRoomParams): String {
return createLocalRoomTask.execute(createRoomParams)
}
override fun getRoom(roomId: String): Room? {
return roomGetter.getRoom(roomId)
}

View file

@ -43,7 +43,9 @@ import org.matrix.android.sdk.internal.session.room.alias.DefaultGetRoomLocalAli
import org.matrix.android.sdk.internal.session.room.alias.DeleteRoomAliasTask
import org.matrix.android.sdk.internal.session.room.alias.GetRoomIdByAliasTask
import org.matrix.android.sdk.internal.session.room.alias.GetRoomLocalAliasesTask
import org.matrix.android.sdk.internal.session.room.create.CreateLocalRoomTask
import org.matrix.android.sdk.internal.session.room.create.CreateRoomTask
import org.matrix.android.sdk.internal.session.room.create.DefaultCreateLocalRoomTask
import org.matrix.android.sdk.internal.session.room.create.DefaultCreateRoomTask
import org.matrix.android.sdk.internal.session.room.directory.DefaultGetPublicRoomTask
import org.matrix.android.sdk.internal.session.room.directory.DefaultGetRoomDirectoryVisibilityTask
@ -192,6 +194,9 @@ internal abstract class RoomModule {
@Binds
abstract fun bindCreateRoomTask(task: DefaultCreateRoomTask): CreateRoomTask
@Binds
abstract fun bindCreateLocalRoomTask(task: DefaultCreateLocalRoomTask): CreateLocalRoomTask
@Binds
abstract fun bindGetPublicRoomTask(task: DefaultGetPublicRoomTask): GetPublicRoomTask

View file

@ -0,0 +1,267 @@
/*
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.internal.session.room.create
import com.zhuinden.monarchy.Monarchy
import io.realm.Realm
import io.realm.RealmConfiguration
import io.realm.kotlin.createObject
import kotlinx.coroutines.TimeoutCancellationException
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.session.events.model.Content
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.toContent
import org.matrix.android.sdk.api.session.room.failure.CreateRoomFailure
import org.matrix.android.sdk.api.session.room.model.GuestAccess
import org.matrix.android.sdk.api.session.room.model.Membership
import org.matrix.android.sdk.api.session.room.model.RoomDirectoryVisibility
import org.matrix.android.sdk.api.session.room.model.RoomHistoryVisibility
import org.matrix.android.sdk.api.session.room.model.RoomMemberContent
import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams
import org.matrix.android.sdk.api.session.room.model.create.RoomCreateContent
import org.matrix.android.sdk.api.session.room.model.localecho.RoomLocalEcho
import org.matrix.android.sdk.api.session.room.send.SendState
import org.matrix.android.sdk.api.session.sync.model.RoomSyncSummary
import org.matrix.android.sdk.api.session.user.UserService
import org.matrix.android.sdk.api.session.user.model.User
import org.matrix.android.sdk.internal.database.awaitNotEmptyResult
import org.matrix.android.sdk.internal.database.helper.addTimelineEvent
import org.matrix.android.sdk.internal.database.mapper.asDomain
import org.matrix.android.sdk.internal.database.mapper.toEntity
import org.matrix.android.sdk.internal.database.model.ChunkEntity
import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntity
import org.matrix.android.sdk.internal.database.model.EventInsertType
import org.matrix.android.sdk.internal.database.model.RoomEntity
import org.matrix.android.sdk.internal.database.model.RoomSummaryEntity
import org.matrix.android.sdk.internal.database.model.RoomSummaryEntityFields
import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore
import org.matrix.android.sdk.internal.database.query.getOrCreate
import org.matrix.android.sdk.internal.database.query.getOrNull
import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.di.UserId
import org.matrix.android.sdk.internal.session.events.getFixedRoomMemberContent
import org.matrix.android.sdk.internal.session.room.membership.RoomMemberEventHandler
import org.matrix.android.sdk.internal.session.room.summary.RoomSummaryUpdater
import org.matrix.android.sdk.internal.session.room.timeline.PaginationDirection
import org.matrix.android.sdk.internal.task.Task
import org.matrix.android.sdk.internal.util.awaitTransaction
import org.matrix.android.sdk.internal.util.time.Clock
import java.util.UUID
import java.util.concurrent.TimeUnit
import javax.inject.Inject
internal interface CreateLocalRoomTask : Task<CreateRoomParams, String>
internal class DefaultCreateLocalRoomTask @Inject constructor(
@UserId private val userId: String,
@SessionDatabase private val monarchy: Monarchy,
private val roomMemberEventHandler: RoomMemberEventHandler,
private val roomSummaryUpdater: RoomSummaryUpdater,
@SessionDatabase private val realmConfiguration: RealmConfiguration,
private val createRoomBodyBuilder: CreateRoomBodyBuilder,
private val userService: UserService,
private val clock: Clock,
) : CreateLocalRoomTask {
override suspend fun execute(params: CreateRoomParams): String {
val createRoomBody = createRoomBodyBuilder.build(params.withDefault())
val roomId = RoomLocalEcho.createLocalEchoId()
monarchy.awaitTransaction { realm ->
createLocalRoomEntity(realm, roomId, createRoomBody)
createLocalRoomSummaryEntity(realm, roomId, createRoomBody)
}
// Wait for room to be created in DB
try {
awaitNotEmptyResult(realmConfiguration, TimeUnit.MINUTES.toMillis(1L)) { realm ->
realm.where(RoomSummaryEntity::class.java)
.equalTo(RoomSummaryEntityFields.ROOM_ID, roomId)
.equalTo(RoomSummaryEntityFields.MEMBERSHIP_STR, Membership.JOIN.name)
}
} catch (exception: TimeoutCancellationException) {
throw CreateRoomFailure.CreatedWithTimeout(roomId)
}
return roomId
}
/**
* Create a local room entity from the given room creation params.
* This will also generate and store in database the chunk and the events related to the room params in order to retrieve and display the local room.
*/
private suspend fun createLocalRoomEntity(realm: Realm, roomId: String, createRoomBody: CreateRoomBody) {
RoomEntity.getOrCreate(realm, roomId).apply {
membership = Membership.JOIN
chunks.add(createLocalRoomChunk(realm, roomId, createRoomBody))
}
}
private fun createLocalRoomSummaryEntity(realm: Realm, roomId: String, createRoomBody: CreateRoomBody) {
val otherUserId = createRoomBody.getDirectUserId()
if (otherUserId != null) {
RoomSummaryEntity.getOrCreate(realm, roomId).apply {
isDirect = true
directUserId = otherUserId
}
}
roomSummaryUpdater.update(
realm = realm,
roomId = roomId,
membership = Membership.JOIN,
roomSummary = RoomSyncSummary(
heroes = createRoomBody.invitedUserIds.orEmpty().take(5),
joinedMembersCount = 1,
invitedMembersCount = createRoomBody.invitedUserIds?.size ?: 0
),
updateMembers = !createRoomBody.invitedUserIds.isNullOrEmpty()
)
}
/**
* Create a single chunk containing the necessary events to display the local room.
*
* @param realm the current instance of realm
* @param roomId the id of the local room
* @param createRoomBody the room creation params
*
* @return a chunk entity
*/
private suspend fun createLocalRoomChunk(realm: Realm, roomId: String, createRoomBody: CreateRoomBody): ChunkEntity {
val chunkEntity = realm.createObject<ChunkEntity>().apply {
isLastBackward = true
isLastForward = true
}
val eventList = createLocalRoomEvents(createRoomBody)
val eventIds = ArrayList<String>(eventList.size)
val roomMemberContentsByUser = HashMap<String, RoomMemberContent?>()
for (event in eventList) {
if (event.eventId == null || event.senderId == null || event.type == null) {
continue
}
eventIds.add(event.eventId)
val eventEntity = event.toEntity(roomId, SendState.SYNCED, null).copyToRealmOrIgnore(realm, EventInsertType.INCREMENTAL_SYNC)
if (event.stateKey != null) {
CurrentStateEventEntity.getOrCreate(realm, roomId, event.stateKey, event.type).apply {
eventId = event.eventId
root = eventEntity
}
if (event.type == EventType.STATE_ROOM_MEMBER) {
roomMemberContentsByUser[event.stateKey] = event.getFixedRoomMemberContent()
roomMemberEventHandler.handle(realm, roomId, event, false)
}
}
roomMemberContentsByUser.getOrPut(event.senderId) {
// If we don't have any new state on this user, get it from db
val rootStateEvent = CurrentStateEventEntity.getOrNull(realm, roomId, event.senderId, EventType.STATE_ROOM_MEMBER)?.root
rootStateEvent?.asDomain()?.getFixedRoomMemberContent()
}
chunkEntity.addTimelineEvent(
roomId = roomId,
eventEntity = eventEntity,
direction = PaginationDirection.FORWARDS,
roomMemberContentsByUser = roomMemberContentsByUser
)
}
return chunkEntity
}
/**
* Build the list of the events related to the room creation params.
*
* @param createRoomBody the room creation params
*
* @return the list of events
*/
private suspend fun createLocalRoomEvents(createRoomBody: CreateRoomBody): List<Event> {
val myUser = userService.getUser(userId) ?: User(userId)
val invitedUsers = createRoomBody.invitedUserIds.orEmpty()
.mapNotNull { tryOrNull { userService.resolveUser(it) } }
val createRoomEvent = createFakeEvent(
type = EventType.STATE_ROOM_CREATE,
content = RoomCreateContent(
creator = userId
).toContent()
)
val myRoomMemberEvent = createFakeEvent(
type = EventType.STATE_ROOM_MEMBER,
content = RoomMemberContent(
membership = Membership.JOIN,
displayName = myUser.displayName,
avatarUrl = myUser.avatarUrl
).toContent(),
stateKey = userId
)
val roomMemberEvents = invitedUsers.map {
createFakeEvent(
type = EventType.STATE_ROOM_MEMBER,
content = RoomMemberContent(
isDirect = createRoomBody.isDirect.orFalse(),
membership = Membership.INVITE,
displayName = it.displayName,
avatarUrl = it.avatarUrl
).toContent(),
stateKey = it.userId
)
}
return buildList {
add(createRoomEvent)
add(myRoomMemberEvent)
addAll(createRoomBody.initialStates.orEmpty().map { createFakeEvent(it.type, it.content, it.stateKey) })
addAll(roomMemberEvents)
}
}
/**
* Generate a local event from the given parameters.
*
* @param type the event type, see [EventType]
* @param content the content of the Event
* @param stateKey the stateKey, if any
*
* @return a fake event
*/
private fun createFakeEvent(type: String?, content: Content?, stateKey: String? = ""): Event {
return Event(
type = type,
senderId = userId,
stateKey = stateKey,
content = content,
originServerTs = clock.epochMillis(),
eventId = UUID.randomUUID().toString()
)
}
/**
* Setup default values to the CreateRoomParams as the room is created locally (the default values will not be defined by the server).
*/
private fun CreateRoomParams.withDefault() = this.apply {
if (visibility == null) visibility = RoomDirectoryVisibility.PRIVATE
if (historyVisibility == null) historyVisibility = RoomHistoryVisibility.SHARED
if (guestAccess == null) guestAccess = GuestAccess.Forbidden
}
}

View file

@ -120,3 +120,20 @@ internal data class CreateRoomBody(
@Json(name = "room_version")
val roomVersion: String?
)
/**
* Tells if the created room can be a direct chat one.
*
* @return true if it is a direct chat
*/
private fun CreateRoomBody.isDirect(): Boolean {
return preset == CreateRoomPreset.PRESET_TRUSTED_PRIVATE_CHAT && isDirect == true
}
internal fun CreateRoomBody.getDirectUserId(): String? {
return if (isDirect()) {
invitedUserIds?.firstOrNull()
?: invite3pids?.firstOrNull()?.address
?: throw IllegalStateException("You can't create a direct room without an invitedUser")
} else null
}

View file

@ -62,11 +62,6 @@ internal class DefaultCreateRoomTask @Inject constructor(
) : CreateRoomTask {
override suspend fun execute(params: CreateRoomParams): String {
val otherUserId = if (params.isDirect()) {
params.getFirstInvitedUserId()
?: throw IllegalStateException("You can't create a direct room without an invitedUser")
} else null
if (params.preset == CreateRoomPreset.PRESET_PUBLIC_CHAT) {
try {
aliasAvailabilityChecker.check(params.roomAliasName)
@ -111,14 +106,13 @@ internal class DefaultCreateRoomTask @Inject constructor(
RoomSummaryEntity.where(it, roomId).findFirst()?.lastActivityTime = clock.epochMillis()
}
if (otherUserId != null) {
handleDirectChatCreation(roomId, otherUserId)
}
handleDirectChatCreation(roomId, createRoomBody.getDirectUserId())
setReadMarkers(roomId)
return roomId
}
private suspend fun handleDirectChatCreation(roomId: String, otherUserId: String) {
private suspend fun handleDirectChatCreation(roomId: String, otherUserId: String?) {
otherUserId ?: return // This is not a direct room
monarchy.awaitTransaction { realm ->
RoomSummaryEntity.where(realm, roomId).findFirst()?.apply {
this.directUserId = otherUserId
@ -133,21 +127,4 @@ internal class DefaultCreateRoomTask @Inject constructor(
val setReadMarkerParams = SetReadMarkersTask.Params(roomId, forceReadReceipt = true, forceReadMarker = true)
return readMarkersTask.execute(setReadMarkerParams)
}
/**
* Tells if the created room can be a direct chat one.
*
* @return true if it is a direct chat
*/
private fun CreateRoomParams.isDirect(): Boolean {
return preset == CreateRoomPreset.PRESET_TRUSTED_PRIVATE_CHAT &&
isDirect == true
}
/**
* @return the first invited user id
*/
private fun CreateRoomParams.getFirstInvitedUserId(): String? {
return invitedUserIds.firstOrNull() ?: invite3pids.firstOrNull()?.value
}
}

View file

@ -32,6 +32,7 @@ import kotlinx.coroutines.withContext
import okhttp3.internal.closeQuietly
import org.matrix.android.sdk.api.MatrixCoroutineDispatchers
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.session.room.model.localecho.RoomLocalEcho
import org.matrix.android.sdk.api.session.room.timeline.Timeline
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings
@ -383,6 +384,7 @@ internal class DefaultTimeline(
}
private suspend fun loadRoomMembersIfNeeded() {
if (RoomLocalEcho.isLocalEchoId(roomId)) return
val loadRoomMembersParam = LoadRoomMembersTask.Params(roomId)
try {
loadRoomMembersTask.execute(loadRoomMembersParam)

View file

@ -20,10 +20,12 @@ import im.vector.app.core.platform.VectorViewModelAction
import im.vector.app.features.userdirectory.PendingSelection
sealed class CreateDirectRoomAction : VectorViewModelAction {
data class CreateRoomAndInviteSelectedUsers(
data class PrepareRoomWithSelectedUsers(
val selections: Set<PendingSelection>
) : CreateDirectRoomAction()
object CreateRoomAndInviteSelectedUsers : CreateDirectRoomAction()
data class QrScannedAction(
val result: String
) : CreateDirectRoomAction()

View file

@ -161,7 +161,7 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() {
private fun onMenuItemSelected(action: UserListSharedAction.OnMenuItemSelected) {
if (action.itemId == R.id.action_create_direct_room) {
viewModel.handle(CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers(action.selections))
viewModel.handle(CreateDirectRoomAction.PrepareRoomWithSelectedUsers(action.selections))
}
}

View file

@ -61,7 +61,8 @@ class CreateDirectRoomViewModel @AssistedInject constructor(
override fun handle(action: CreateDirectRoomAction) {
when (action) {
is CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers -> onSubmitInvitees(action.selections)
is CreateDirectRoomAction.PrepareRoomWithSelectedUsers -> onSubmitInvitees(action.selections)
is CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers -> onCreateRoomWithInvitees()
is CreateDirectRoomAction.QrScannedAction -> onCodeParsed(action)
}
}
@ -96,16 +97,18 @@ class CreateDirectRoomViewModel @AssistedInject constructor(
}
if (existingRoomId != null) {
// Do not create a new DM, just tell that the creation is successful by passing the existing roomId
setState {
copy(createAndInviteState = Success(existingRoomId))
}
setState { copy(createAndInviteState = Success(existingRoomId)) }
} else {
// Create the DM
createRoomAndInviteSelectedUsers(selections)
createLocalRoomWithSelectedUsers(selections)
}
}
private fun createRoomAndInviteSelectedUsers(selections: Set<PendingSelection>) {
private fun onCreateRoomWithInvitees() {
// Create the DM
withState { createLocalRoomWithSelectedUsers(it.pendingSelections) }
}
private fun createLocalRoomWithSelectedUsers(selections: Set<PendingSelection>) {
setState { copy(createAndInviteState = Loading()) }
viewModelScope.launch(Dispatchers.IO) {
@ -127,8 +130,7 @@ class CreateDirectRoomViewModel @AssistedInject constructor(
val result = runCatchingToAsync {
if (vectorFeatures.shouldStartDmOnFirstMessage()) {
// Todo: Prepare direct room creation
throw Throwable("Start DM on first message is not implemented yet.")
session.roomService().createLocalRoom(roomParams)
} else {
session.roomService().createRoom(roomParams)
}

View file

@ -19,7 +19,9 @@ package im.vector.app.features.createdirect
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.Uninitialized
import im.vector.app.features.userdirectory.PendingSelection
data class CreateDirectRoomViewState(
val pendingSelections: Set<PendingSelection> = emptySet(),
val createAndInviteState: Async<String> = Uninitialized
) : MavericksState