diff --git a/changelog.d/5525.wip b/changelog.d/5525.wip new file mode 100644 index 0000000000..0d54c06b6a --- /dev/null +++ b/changelog.d/5525.wip @@ -0,0 +1 @@ +Create DM room only on first message - Create the DM and navigate to the new room after sending an event diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendEventTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendEventTask.kt index bb14b417dd..f51a5165c0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendEventTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendEventTask.kt @@ -16,10 +16,12 @@ package org.matrix.android.sdk.internal.crypto.tasks import org.matrix.android.sdk.api.session.events.model.Event +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.internal.network.GlobalErrorReceiver import org.matrix.android.sdk.internal.network.executeRequest import org.matrix.android.sdk.internal.session.room.RoomAPI +import org.matrix.android.sdk.internal.session.room.create.CreateRoomFromLocalRoomTask import org.matrix.android.sdk.internal.session.room.membership.LoadRoomMembersTask import org.matrix.android.sdk.internal.session.room.send.LocalEchoRepository import org.matrix.android.sdk.internal.task.Task @@ -37,12 +39,18 @@ internal class DefaultSendEventTask @Inject constructor( private val localEchoRepository: LocalEchoRepository, private val encryptEventTask: EncryptEventTask, private val loadRoomMembersTask: LoadRoomMembersTask, + private val createRoomFromLocalRoomTask: CreateRoomFromLocalRoomTask, private val roomAPI: RoomAPI, private val globalErrorReceiver: GlobalErrorReceiver ) : SendEventTask { override suspend fun execute(params: SendEventTask.Params): String { try { + if (RoomLocalEcho.isLocalEchoId(params.event.roomId.orEmpty())) { + // Room is local, so create a real one and send the event to this new room + return createRoomAndSendEvent(params) + } + // Make sure to load all members in the room before sending the event. params.event.roomId ?.takeIf { params.encrypt } @@ -78,6 +86,12 @@ internal class DefaultSendEventTask @Inject constructor( } } + private suspend fun createRoomAndSendEvent(params: SendEventTask.Params): String { + val roomId = createRoomFromLocalRoomTask.execute(CreateRoomFromLocalRoomTask.Params(params.event.roomId.orEmpty())) + Timber.d("State event: convert local room (${params.event.roomId}) to existing room ($roomId) before sending the event.") + return execute(params.copy(event = params.event.copy(roomId = roomId))) + } + @Throws private suspend fun handleEncryption(params: SendEventTask.Params): Event { if (params.encrypt && !params.event.isEncrypted()) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt index d01324a35f..218ce0a4d5 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt @@ -44,8 +44,10 @@ 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.CreateRoomFromLocalRoomTask 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.DefaultCreateRoomFromLocalRoomTask import org.matrix.android.sdk.internal.session.room.create.DefaultCreateRoomTask import org.matrix.android.sdk.internal.session.room.delete.DefaultDeleteLocalRoomTask import org.matrix.android.sdk.internal.session.room.delete.DeleteLocalRoomTask @@ -213,6 +215,9 @@ internal abstract class RoomModule { @Binds abstract fun bindCreateLocalRoomTask(task: DefaultCreateLocalRoomTask): CreateLocalRoomTask + @Binds + abstract fun bindCreateRoomFromLocalRoomTask(task: DefaultCreateRoomFromLocalRoomTask): CreateRoomFromLocalRoomTask + @Binds abstract fun bindDeleteLocalRoomTask(task: DefaultDeleteLocalRoomTask): DeleteLocalRoomTask diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomFromLocalRoomTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomFromLocalRoomTask.kt new file mode 100644 index 0000000000..516220d452 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomFromLocalRoomTask.kt @@ -0,0 +1,252 @@ +/* + * 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 android.util.Patterns +import androidx.core.net.toUri +import com.google.i18n.phonenumbers.NumberParseException +import com.google.i18n.phonenumbers.PhoneNumberUtil +import com.zhuinden.monarchy.Monarchy +import io.realm.Realm +import org.matrix.android.sdk.api.extensions.ensurePrefix +import org.matrix.android.sdk.api.query.QueryStringValue +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.events.model.toModel +import org.matrix.android.sdk.api.session.identity.ThreePid +import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent +import org.matrix.android.sdk.api.session.room.model.RoomAliasesContent +import org.matrix.android.sdk.api.session.room.model.RoomAvatarContent +import org.matrix.android.sdk.api.session.room.model.RoomCanonicalAliasContent +import org.matrix.android.sdk.api.session.room.model.RoomGuestAccessContent +import org.matrix.android.sdk.api.session.room.model.RoomHistoryVisibilityContent +import org.matrix.android.sdk.api.session.room.model.RoomJoinRules +import org.matrix.android.sdk.api.session.room.model.RoomJoinRulesContent +import org.matrix.android.sdk.api.session.room.model.RoomMemberContent +import org.matrix.android.sdk.api.session.room.model.RoomNameContent +import org.matrix.android.sdk.api.session.room.model.RoomTopicContent +import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams +import org.matrix.android.sdk.api.session.room.model.create.CreateRoomPreset +import org.matrix.android.sdk.api.session.room.model.tombstone.RoomTombstoneContent +import org.matrix.android.sdk.api.session.room.send.SendState +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.CurrentStateEventEntity +import org.matrix.android.sdk.internal.database.model.EventEntity +import org.matrix.android.sdk.internal.database.model.EventInsertType +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.where +import org.matrix.android.sdk.internal.database.query.whereRoomId +import org.matrix.android.sdk.internal.di.SessionDatabase +import org.matrix.android.sdk.internal.di.UserId +import org.matrix.android.sdk.internal.session.room.state.StateEventDataSource +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 javax.inject.Inject + +/** + * Create a Room from a "fake" local room. + * The configuration of the local room will be use to configure the new room. + * The potential local room members will also be invited to this new room. + * + * A "fake" local tombstone event will be created to indicate that the local room has been replacing by the new one. + */ +internal interface CreateRoomFromLocalRoomTask : Task { + data class Params(val localRoomId: String) +} + +internal class DefaultCreateRoomFromLocalRoomTask @Inject constructor( + @UserId private val userId: String, + @SessionDatabase private val monarchy: Monarchy, + private val createRoomTask: CreateRoomTask, + private val stateEventDataSource: StateEventDataSource, + private val clock: Clock, +) : CreateRoomFromLocalRoomTask { + + override suspend fun execute(params: CreateRoomFromLocalRoomTask.Params): String { + val replacementRoomId = stateEventDataSource.getStateEvent(params.localRoomId, EventType.STATE_ROOM_TOMBSTONE, QueryStringValue.NoCondition) + ?.content?.toModel() + ?.replacementRoomId + + if (replacementRoomId != null) { + return replacementRoomId + } + + val createRoomParams = getCreateRoomParams(params) + val roomId = createRoomTask.execute(createRoomParams) + createTombstoneEvent(params, roomId) + return roomId + } + + /** + * Retrieve the room configuration by parsing the state events related to the local room. + */ + private suspend fun getCreateRoomParams(params: CreateRoomFromLocalRoomTask.Params): CreateRoomParams { + var createRoomParams = CreateRoomParams() + monarchy.awaitTransaction { realm -> + val stateEvents = CurrentStateEventEntity.whereRoomId(realm, params.localRoomId).findAll() + stateEvents.forEach { event -> + createRoomParams = when (event.type) { + EventType.STATE_ROOM_MEMBER -> handleRoomMemberEvent(realm, event, createRoomParams) + EventType.STATE_ROOM_HISTORY_VISIBILITY -> handleRoomHistoryVisibilityEvent(realm, event, createRoomParams) + EventType.STATE_ROOM_ALIASES -> handleRoomAliasesEvent(realm, event, createRoomParams) + EventType.STATE_ROOM_AVATAR -> handleRoomAvatarEvent(realm, event, createRoomParams) + EventType.STATE_ROOM_CANONICAL_ALIAS -> handleRoomCanonicalAliasEvent(realm, event, createRoomParams) + EventType.STATE_ROOM_GUEST_ACCESS -> handleRoomGuestAccessEvent(realm, event, createRoomParams) + EventType.STATE_ROOM_ENCRYPTION -> handleRoomEncryptionEvent(createRoomParams) + EventType.STATE_ROOM_POWER_LEVELS -> handleRoomPowerRoomLevelsEvent(realm, event, createRoomParams) + EventType.STATE_ROOM_NAME -> handleRoomNameEvent(realm, event, createRoomParams) + EventType.STATE_ROOM_TOPIC -> handleRoomTopicEvent(realm, event, createRoomParams) + EventType.STATE_ROOM_THIRD_PARTY_INVITE -> handleRoomThirdPartyInviteEvent(event, createRoomParams) + EventType.STATE_ROOM_JOIN_RULES -> handleRoomJoinRulesEvent(realm, event, createRoomParams) + else -> createRoomParams + } + } + } + return createRoomParams + } + + /** + * Create a Tombstone event to indicate that the local room has been replaced by a new one. + */ + private suspend fun createTombstoneEvent(params: CreateRoomFromLocalRoomTask.Params, roomId: String) { + val now = clock.epochMillis() + val event = Event( + type = EventType.STATE_ROOM_TOMBSTONE, + senderId = userId, + originServerTs = now, + stateKey = "", + eventId = UUID.randomUUID().toString(), + content = RoomTombstoneContent( + replacementRoomId = roomId + ).toContent() + ) + monarchy.awaitTransaction { realm -> + val eventEntity = event.toEntity(params.localRoomId, SendState.SYNCED, now).copyToRealmOrIgnore(realm, EventInsertType.INCREMENTAL_SYNC) + if (event.stateKey != null && event.type != null && event.eventId != null) { + CurrentStateEventEntity.getOrCreate(realm, params.localRoomId, event.stateKey, event.type).apply { + eventId = event.eventId + root = eventEntity + } + } + } + } + + /* ========================================================================================== + * Local events handling + * ========================================================================================== */ + + private fun handleRoomMemberEvent(realm: Realm, event: CurrentStateEventEntity, params: CreateRoomParams): CreateRoomParams = params.apply { + val content = getEventContent(realm, event.eventId) ?: return@apply + invitedUserIds.add(event.stateKey) + if (content.isDirect) { + setDirectMessage() + } + } + + private fun handleRoomHistoryVisibilityEvent(realm: Realm, event: CurrentStateEventEntity, params: CreateRoomParams): CreateRoomParams = params.apply { + val content = getEventContent(realm, event.eventId) ?: return@apply + historyVisibility = content.historyVisibility + } + + private fun handleRoomAliasesEvent(realm: Realm, event: CurrentStateEventEntity, params: CreateRoomParams): CreateRoomParams = params.apply { + val content = getEventContent(realm, event.eventId) ?: return@apply + roomAliasName = content.aliases.firstOrNull()?.substringAfter("#")?.substringBefore(":") + } + + private fun handleRoomAvatarEvent(realm: Realm, event: CurrentStateEventEntity, params: CreateRoomParams): CreateRoomParams = params.apply { + val content = getEventContent(realm, event.eventId) ?: return@apply + avatarUri = content.avatarUrl?.toUri() + } + + private fun handleRoomCanonicalAliasEvent(realm: Realm, event: CurrentStateEventEntity, params: CreateRoomParams): CreateRoomParams = params.apply { + val content = getEventContent(realm, event.eventId) ?: return@apply + roomAliasName = content.canonicalAlias?.substringAfter("#")?.substringBefore(":") + } + + private fun handleRoomGuestAccessEvent(realm: Realm, event: CurrentStateEventEntity, params: CreateRoomParams): CreateRoomParams = params.apply { + val content = getEventContent(realm, event.eventId) ?: return@apply + guestAccess = content.guestAccess + } + + private fun handleRoomEncryptionEvent(params: CreateRoomParams): CreateRoomParams = params.apply { + // Having an encryption event means the room is encrypted, so just enable it again + enableEncryption() + } + + private fun handleRoomPowerRoomLevelsEvent(realm: Realm, event: CurrentStateEventEntity, params: CreateRoomParams): CreateRoomParams = params.apply { + val content = getEventContent(realm, event.eventId) ?: return@apply + powerLevelContentOverride = content + } + + private fun handleRoomNameEvent(realm: Realm, event: CurrentStateEventEntity, params: CreateRoomParams): CreateRoomParams = params.apply { + val content = getEventContent(realm, event.eventId) ?: return@apply + name = content.name + } + + private fun handleRoomTopicEvent(realm: Realm, event: CurrentStateEventEntity, params: CreateRoomParams): CreateRoomParams = params.apply { + val content = getEventContent(realm, event.eventId) ?: return@apply + topic = content.topic + } + + private fun handleRoomThirdPartyInviteEvent(event: CurrentStateEventEntity, params: CreateRoomParams): CreateRoomParams = params.apply { + when { + event.stateKey.isEmail() -> invite3pids.add(ThreePid.Email(event.stateKey)) + event.stateKey.isMsisdn() -> invite3pids.add(ThreePid.Msisdn(event.stateKey)) + } + } + + private fun handleRoomJoinRulesEvent(realm: Realm, event: CurrentStateEventEntity, params: CreateRoomParams): CreateRoomParams = params.apply { + val content = getEventContent(realm, event.eventId) ?: return@apply + preset = when { + // If preset has already been set for direct chat, keep it + preset == CreateRoomPreset.PRESET_TRUSTED_PRIVATE_CHAT -> CreateRoomPreset.PRESET_TRUSTED_PRIVATE_CHAT + content.joinRules == RoomJoinRules.PUBLIC -> CreateRoomPreset.PRESET_PUBLIC_CHAT + content.joinRules == RoomJoinRules.INVITE -> CreateRoomPreset.PRESET_PRIVATE_CHAT + else -> null + } + } + + /* ========================================================================================== + * Helper methods + * ========================================================================================== */ + + private inline fun getEventContent(realm: Realm, eventId: String): T? { + return EventEntity.where(realm, eventId).findFirst()?.asDomain()?.getClearContent().toModel() + } + + /** + * Check if a CharSequence is an email. + */ + private fun CharSequence.isEmail() = Patterns.EMAIL_ADDRESS.matcher(this).matches() + + /** + * Check if a CharSequence is a phone number. + */ + private fun CharSequence.isMsisdn(): Boolean { + return try { + PhoneNumberUtil.getInstance().parse(ensurePrefix("+"), null) + true + } catch (e: NumberParseException) { + false + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/SendStateTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/SendStateTask.kt index 59c9de2932..ecc452edb3 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/SendStateTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/state/SendStateTask.kt @@ -16,10 +16,12 @@ package org.matrix.android.sdk.internal.session.room.state +import org.matrix.android.sdk.api.session.room.model.localecho.RoomLocalEcho import org.matrix.android.sdk.api.util.JsonDict import org.matrix.android.sdk.internal.network.GlobalErrorReceiver import org.matrix.android.sdk.internal.network.executeRequest import org.matrix.android.sdk.internal.session.room.RoomAPI +import org.matrix.android.sdk.internal.session.room.create.CreateRoomFromLocalRoomTask import org.matrix.android.sdk.internal.task.Task import timber.log.Timber import javax.inject.Inject @@ -35,28 +37,40 @@ internal interface SendStateTask : Task { internal class DefaultSendStateTask @Inject constructor( private val roomAPI: RoomAPI, - private val globalErrorReceiver: GlobalErrorReceiver + private val globalErrorReceiver: GlobalErrorReceiver, + private val createRoomFromLocalRoomTask: CreateRoomFromLocalRoomTask, ) : SendStateTask { override suspend fun execute(params: SendStateTask.Params): String { return executeRequest(globalErrorReceiver) { - val response = if (params.stateKey.isEmpty()) { - roomAPI.sendStateEvent( - roomId = params.roomId, - stateEventType = params.eventType, - params = params.body - ) + if (RoomLocalEcho.isLocalEchoId(params.roomId)) { + // Room is local, so create a real one and send the event to this new room + createRoomAndSendEvent(params) } else { - roomAPI.sendStateEvent( - roomId = params.roomId, - stateEventType = params.eventType, - stateKey = params.stateKey, - params = params.body - ) - } - response.eventId.also { - Timber.d("State event: $it just sent in room ${params.roomId}") + val response = if (params.stateKey.isEmpty()) { + roomAPI.sendStateEvent( + roomId = params.roomId, + stateEventType = params.eventType, + params = params.body + ) + } else { + roomAPI.sendStateEvent( + roomId = params.roomId, + stateEventType = params.eventType, + stateKey = params.stateKey, + params = params.body + ) + } + response.eventId.also { + Timber.d("State event: $it just sent in room ${params.roomId}") + } } } } + + private suspend fun createRoomAndSendEvent(params: SendStateTask.Params): String { + val roomId = createRoomFromLocalRoomTask.execute(CreateRoomFromLocalRoomTask.Params(params.roomId)) + Timber.d("State event: convert local room (${params.roomId}) to existing room ($roomId) before sending the event.") + return execute(params.copy(roomId = roomId)) + } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt index c0f90aba7a..cea845a490 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt @@ -82,6 +82,7 @@ import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.raw.RawService import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.crypto.MXCryptoError +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.LocalEcho import org.matrix.android.sdk.api.session.events.model.RelationType @@ -1269,11 +1270,26 @@ class TimelineViewModel @AssistedInject constructor( } } room.getStateEvent(EventType.STATE_ROOM_TOMBSTONE, QueryStringValue.IsEmpty)?.also { - setState { copy(tombstoneEvent = it) } + onRoomTombstoneUpdated(it) } } } + private var roomTombstoneHandled = false + private fun onRoomTombstoneUpdated(tombstoneEvent: Event) = withState { state -> + if (roomTombstoneHandled) return@withState + if (state.isLocalRoom()) { + // Local room has been replaced, so navigate to the new room + val roomId = tombstoneEvent.getClearContent()?.toModel() + ?.replacementRoomId + ?: return@withState + _viewEvents.post(RoomDetailViewEvents.OpenRoom(roomId, closeCurrentRoom = true)) + roomTombstoneHandled = true + } else { + setState { copy(tombstoneEvent = tombstoneEvent) } + } + } + /** * Navigates to the appropriate event (by paginating the thread timeline until the event is found * in the snapshot. The main reason for this function is to support the /relations api