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/changelog.d/6889.wip b/changelog.d/6889.wip new file mode 100644 index 0000000000..067973aad9 --- /dev/null +++ b/changelog.d/6889.wip @@ -0,0 +1 @@ +[App Layout] new room invites screen diff --git a/changelog.d/6907.wip b/changelog.d/6907.wip new file mode 100644 index 0000000000..a8d887c66b --- /dev/null +++ b/changelog.d/6907.wip @@ -0,0 +1 @@ +[New Layout] Changes space sheet to accordion-style with expandable subspaces diff --git a/changelog.d/6926.misc b/changelog.d/6926.misc new file mode 100644 index 0000000000..dc1330d9fc --- /dev/null +++ b/changelog.d/6926.misc @@ -0,0 +1 @@ +Focus input field when editing homeserver address to speed up login and registration. \ No newline at end of file diff --git a/dependencies.gradle b/dependencies.gradle index 80dc203740..7a9ed3f931 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -104,6 +104,7 @@ ext.libs = [ 'moshi' : "com.squareup.moshi:moshi:$moshi", 'moshiKt' : "com.squareup.moshi:moshi-kotlin:$moshi", 'moshiKotlin' : "com.squareup.moshi:moshi-kotlin-codegen:$moshi", + 'moshiAdapters' : "com.squareup.moshi:moshi-adapters:$moshi", 'retrofit' : "com.squareup.retrofit2:retrofit:$retrofit", 'retrofitMoshi' : "com.squareup.retrofit2:converter-moshi:$retrofit" ], diff --git a/matrix-sdk-android/build.gradle b/matrix-sdk-android/build.gradle index fcd1e7d622..faa798c9dc 100644 --- a/matrix-sdk-android/build.gradle +++ b/matrix-sdk-android/build.gradle @@ -163,6 +163,7 @@ dependencies { implementation 'com.squareup.okhttp3:logging-interceptor' implementation libs.squareup.moshi + implementation libs.squareup.moshiAdapters kapt libs.squareup.moshiKotlin api "com.atlassian.commonmark:commonmark:0.13.0" diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt index 8fdbba21c5..84c25776e7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt @@ -70,6 +70,9 @@ object EventType { const val STATE_ROOM_ENCRYPTION = "m.room.encryption" const val STATE_ROOM_SERVER_ACL = "m.room.server_acl" + // This type is for local purposes, it should never be processed by the server + const val LOCAL_STATE_ROOM_THIRD_PARTY_INVITE = "local.room.third_party_invite" + // Call Events const val CALL_INVITE = "m.call.invite" const val CALL_CANDIDATES = "m.call.candidates" diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/identity/ThreePid.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/identity/ThreePid.kt index 6bcf576824..24748f88e4 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/identity/ThreePid.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/identity/ThreePid.kt @@ -18,10 +18,14 @@ package org.matrix.android.sdk.api.session.identity import com.google.i18n.phonenumbers.NumberParseException import com.google.i18n.phonenumbers.PhoneNumberUtil +import com.squareup.moshi.JsonClass import org.matrix.android.sdk.internal.session.profile.ThirdPartyIdentifier sealed class ThreePid(open val value: String) { + @JsonClass(generateAdapter = true) data class Email(val email: String) : ThreePid(email) + + @JsonClass(generateAdapter = true) data class Msisdn(val msisdn: String) : ThreePid(msisdn) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/create/CreateRoomParams.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/create/CreateRoomParams.kt index b7b0cc890b..d6eb7b30d3 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/create/CreateRoomParams.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/create/CreateRoomParams.kt @@ -17,13 +17,16 @@ package org.matrix.android.sdk.api.session.room.model.create import android.net.Uri +import com.squareup.moshi.JsonClass import org.matrix.android.sdk.api.crypto.MXCRYPTO_ALGORITHM_MEGOLM import org.matrix.android.sdk.api.session.identity.ThreePid import org.matrix.android.sdk.api.session.room.model.GuestAccess import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent 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.internal.di.MoshiProvider +@JsonClass(generateAdapter = true) open class CreateRoomParams { /** * A public visibility indicates that the room will be shown in the published room list. @@ -61,12 +64,12 @@ open class CreateRoomParams { * A list of user IDs to invite to the room. * This will tell the server to invite everyone in the list to the newly created room. */ - val invitedUserIds = mutableListOf() + var invitedUserIds = mutableListOf() /** * A list of objects representing third party IDs to invite into the room. */ - val invite3pids = mutableListOf() + var invite3pids = mutableListOf() /** * Initial Guest Access. @@ -99,14 +102,14 @@ open class CreateRoomParams { * The server will clobber the following keys: creator. * Future versions of the specification may allow the server to clobber other keys. */ - val creationContent = mutableMapOf() + var creationContent = mutableMapOf() /** * A list of state events to set in the new room. This allows the user to override the default state events * set in the new room. The expected format of the state events are an object with type, state_key and content keys set. * Takes precedence over events set by preset, but gets overridden by name and topic keys. */ - val initialStates = mutableListOf() + var initialStates = mutableListOf() /** * Set to true to disable federation of this room. @@ -151,7 +154,7 @@ open class CreateRoomParams { * Supported value: MXCRYPTO_ALGORITHM_MEGOLM. */ var algorithm: String? = null - private set + internal set var historyVisibility: RoomHistoryVisibility? = null @@ -161,10 +164,18 @@ open class CreateRoomParams { var roomVersion: String? = null - var featurePreset: RoomFeaturePreset? = null + @Transient var featurePreset: RoomFeaturePreset? = null companion object { - private const val CREATION_CONTENT_KEY_M_FEDERATE = "m.federate" - private const val CREATION_CONTENT_KEY_ROOM_TYPE = "type" + internal const val CREATION_CONTENT_KEY_M_FEDERATE = "m.federate" + internal const val CREATION_CONTENT_KEY_ROOM_TYPE = "type" + + fun fromJson(json: String?): CreateRoomParams? { + return json?.let { MoshiProvider.providesMoshi().adapter(CreateRoomParams::class.java).fromJson(it) } + } } } + +internal fun CreateRoomParams.toJSONString(): String { + return MoshiProvider.providesMoshi().adapter(CreateRoomParams::class.java).toJson(this) +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/create/CreateRoomStateEvent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/create/CreateRoomStateEvent.kt index fcfdc3e333..d89c72c513 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/create/CreateRoomStateEvent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/create/CreateRoomStateEvent.kt @@ -16,8 +16,10 @@ package org.matrix.android.sdk.api.session.room.model.create +import com.squareup.moshi.JsonClass import org.matrix.android.sdk.api.session.events.model.Content +@JsonClass(generateAdapter = true) data class CreateRoomStateEvent( /** * Required. The type of event to send. 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..405757e3b3 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,17 @@ 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 (params.event.isLocalRoomEvent) { + return createRoomAndSendEvent(params) + } + // Make sure to load all members in the room before sending the event. params.event.roomId ?.takeIf { params.encrypt } @@ -78,6 +85,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()) { @@ -91,4 +104,7 @@ internal class DefaultSendEventTask @Inject constructor( } return params.event } + + private val Event.isLocalRoomEvent + get() = RoomLocalEcho.isLocalEchoId(roomId.orEmpty()) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt index b733aa6fc0..0b11863864 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt @@ -52,6 +52,7 @@ import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo032 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo033 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo034 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo035 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo036 import org.matrix.android.sdk.internal.util.Normalizer import org.matrix.android.sdk.internal.util.database.MatrixRealmMigration import javax.inject.Inject @@ -60,7 +61,7 @@ internal class RealmSessionStoreMigration @Inject constructor( private val normalizer: Normalizer ) : MatrixRealmMigration( dbName = "Session", - schemaVersion = 35L, + schemaVersion = 36L, ) { /** * Forces all RealmSessionStoreMigration instances to be equal. @@ -105,5 +106,6 @@ internal class RealmSessionStoreMigration @Inject constructor( if (oldVersion < 33) MigrateSessionTo033(realm).perform() if (oldVersion < 34) MigrateSessionTo034(realm).perform() if (oldVersion < 35) MigrateSessionTo035(realm).perform() + if (oldVersion < 36) MigrateSessionTo036(realm).perform() } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo036.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo036.kt new file mode 100644 index 0000000000..efcb181ecb --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo036.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 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.database.migration + +import io.realm.DynamicRealm +import org.matrix.android.sdk.internal.database.model.LocalRoomSummaryEntityFields +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +internal class MigrateSessionTo036(realm: DynamicRealm) : RealmMigrator(realm, 36) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.create("LocalRoomSummaryEntity") + .addField(LocalRoomSummaryEntityFields.ROOM_ID, String::class.java) + .addPrimaryKey(LocalRoomSummaryEntityFields.ROOM_ID) + .setRequired(LocalRoomSummaryEntityFields.ROOM_ID, true) + .addField(LocalRoomSummaryEntityFields.CREATE_ROOM_PARAMS_STR, String::class.java) + .addRealmObjectField(LocalRoomSummaryEntityFields.ROOM_SUMMARY_ENTITY.`$`, realm.schema.get("RoomSummaryEntity")!!) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/LocalRoomSummaryEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/LocalRoomSummaryEntity.kt new file mode 100644 index 0000000000..fd8331e986 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/LocalRoomSummaryEntity.kt @@ -0,0 +1,39 @@ +/* + * 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.database.model + +import io.realm.RealmObject +import io.realm.annotations.PrimaryKey +import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams +import org.matrix.android.sdk.api.session.room.model.create.toJSONString + +internal open class LocalRoomSummaryEntity( + @PrimaryKey var roomId: String = "", + var roomSummaryEntity: RoomSummaryEntity? = null, + private var createRoomParamsStr: String? = null +) : RealmObject() { + + var createRoomParams: CreateRoomParams? + get() { + return CreateRoomParams.fromJson(createRoomParamsStr) + } + set(value) { + createRoomParamsStr = value?.toJSONString() + } + + companion object +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt index d131589dd1..b222bcb710 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt @@ -35,6 +35,7 @@ import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntit ReadReceiptEntity::class, RoomEntity::class, RoomSummaryEntity::class, + LocalRoomSummaryEntity::class, RoomTagEntity::class, SyncEntity::class, PendingThreePidEntity::class, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/LocalRoomSummaryEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/LocalRoomSummaryEntityQueries.kt new file mode 100644 index 0000000000..527350bedc --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/LocalRoomSummaryEntityQueries.kt @@ -0,0 +1,31 @@ +/* + * 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.database.query + +import io.realm.Realm +import io.realm.RealmQuery +import io.realm.kotlin.where +import org.matrix.android.sdk.internal.database.model.LocalRoomSummaryEntity +import org.matrix.android.sdk.internal.database.model.LocalRoomSummaryEntityFields + +internal fun LocalRoomSummaryEntity.Companion.where(realm: Realm, roomId: String? = null): RealmQuery { + val query = realm.where() + if (roomId != null) { + query.equalTo(LocalRoomSummaryEntityFields.ROOM_ID, roomId) + } + return query +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/di/MoshiProvider.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/di/MoshiProvider.kt index 8f007f227c..0a737d5e64 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/di/MoshiProvider.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/di/MoshiProvider.kt @@ -17,6 +17,8 @@ package org.matrix.android.sdk.internal.di import com.squareup.moshi.Moshi +import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory +import org.matrix.android.sdk.api.session.identity.ThreePid import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.api.session.room.model.message.MessageDefaultContent @@ -60,6 +62,12 @@ internal object MoshiProvider { .registerSubtype(MessagePollResponseContent::class.java, MessageType.MSGTYPE_POLL_RESPONSE) ) .add(SerializeNulls.JSON_ADAPTER_FACTORY) + .add( + PolymorphicJsonAdapterFactory.of(ThreePid::class.java, "type") + .withSubtype(ThreePid.Email::class.java, "email") + .withSubtype(ThreePid.Msisdn::class.java, "msisdn") + .withDefaultValue(null) + ) .build() fun providesMoshi(): Moshi { 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..1475b67276 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 @@ -43,9 +43,13 @@ 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.CreateLocalRoomStateEventsTask 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.DefaultCreateLocalRoomStateEventsTask 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 +217,12 @@ internal abstract class RoomModule { @Binds abstract fun bindCreateLocalRoomTask(task: DefaultCreateLocalRoomTask): CreateLocalRoomTask + @Binds + abstract fun bindCreateLocalRoomStateEventsTask(task: DefaultCreateLocalRoomStateEventsTask): CreateLocalRoomStateEventsTask + + @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/CreateLocalRoomStateEventsTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateLocalRoomStateEventsTask.kt new file mode 100644 index 0000000000..a9ff4970fe --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateLocalRoomStateEventsTask.kt @@ -0,0 +1,299 @@ +/* + * 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 org.matrix.android.sdk.api.MatrixPatterns.getServerName +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.LocalEcho +import org.matrix.android.sdk.api.session.events.model.toContent +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.PowerLevelsContent +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.RoomHistoryVisibility +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.RoomThirdPartyInviteContent +import org.matrix.android.sdk.api.session.room.model.RoomTopicContent +import org.matrix.android.sdk.api.session.room.model.banOrDefault +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.create.RoomCreateContent +import org.matrix.android.sdk.api.session.room.model.eventsDefaultOrDefault +import org.matrix.android.sdk.api.session.room.model.inviteOrDefault +import org.matrix.android.sdk.api.session.room.model.kickOrDefault +import org.matrix.android.sdk.api.session.room.model.redactOrDefault +import org.matrix.android.sdk.api.session.room.model.stateDefaultOrDefault +import org.matrix.android.sdk.api.session.room.model.usersDefaultOrDefault +import org.matrix.android.sdk.api.session.user.UserService +import org.matrix.android.sdk.internal.di.UserId +import org.matrix.android.sdk.internal.session.room.create.CreateLocalRoomStateEventsTask.Params +import org.matrix.android.sdk.internal.session.room.membership.threepid.toThreePid +import org.matrix.android.sdk.internal.task.Task +import org.matrix.android.sdk.internal.util.time.Clock +import javax.inject.Inject + +/** + * Generate a list of local state events from the given [CreateRoomBody]. + * The states events are generated according to the given configuration and following the matrix specification. + * This list reflects as much as possible a list of state events related to a real room configured and got from the server. + * + * Ref: https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3createroom + */ +internal interface CreateLocalRoomStateEventsTask : Task> { + data class Params(val createRoomBody: CreateRoomBody) +} + +internal class DefaultCreateLocalRoomStateEventsTask @Inject constructor( + @UserId private val myUserId: String, + private val userService: UserService, + private val clock: Clock, +) : CreateLocalRoomStateEventsTask { + + private lateinit var createRoomBody: CreateRoomBody + + override suspend fun execute(params: Params): List { + createRoomBody = params.createRoomBody + + // Build the list of the state events following the priorities from the matrix specification + // Changing the order of the events might break the correct display of the room on the client side + return buildList { + createRoomCreateEvent() + createRoomMemberEvents(listOf(myUserId)) + createRoomPowerLevelsEvent() + createRoomAliasEvent() + createRoomPresetEvents() + createRoomInitialStateEvents() + createRoomNameAndTopicStateEvents() + createRoomMemberEvents(createRoomBody.invitedUserIds.orEmpty()) + createRoomThreePidEvents() + createRoomDefaultEvents() + } + } + + /** + * Generate the create state event related to this room. + */ + private fun MutableList.createRoomCreateEvent() { + val roomCreateEvent = createLocalStateEvent( + type = EventType.STATE_ROOM_CREATE, + content = RoomCreateContent( + creator = myUserId, + roomVersion = createRoomBody.roomVersion, + type = (createRoomBody.creationContent as? Map<*, *>)?.get(CreateRoomParams.CREATION_CONTENT_KEY_ROOM_TYPE) as? String + + ).toContent(), + ) + add(roomCreateEvent) + } + + /** + * Generate the create state event related to the power levels using the given overridden values or the default values according to the specification. + * Ref: https://spec.matrix.org/latest/client-server-api/#mroompower_levels + */ + private fun MutableList.createRoomPowerLevelsEvent() { + val powerLevelsContent = createLocalStateEvent( + type = EventType.STATE_ROOM_POWER_LEVELS, + content = (createRoomBody.powerLevelContentOverride ?: PowerLevelsContent()).let { + it.copy( + ban = it.banOrDefault(), + eventsDefault = it.eventsDefaultOrDefault(), + invite = it.inviteOrDefault(), + kick = it.kickOrDefault(), + redact = it.redactOrDefault(), + stateDefault = it.stateDefaultOrDefault(), + usersDefault = it.usersDefaultOrDefault(), + ) + }.toContent(), + ) + add(powerLevelsContent) + } + + /** + * Generate the local room member state events related to the given user ids, if any. + */ + private suspend fun MutableList.createRoomMemberEvents(userIds: List) { + val memberEvents = userIds + .mapNotNull { tryOrNull { userService.resolveUser(it) } } + .map { user -> + createLocalStateEvent( + type = EventType.STATE_ROOM_MEMBER, + content = RoomMemberContent( + isDirect = createRoomBody.isDirect.takeUnless { user.userId == myUserId }.orFalse(), + membership = if (user.userId == myUserId) Membership.JOIN else Membership.INVITE, + displayName = user.displayName, + avatarUrl = user.avatarUrl + ).toContent(), + stateKey = user.userId + ) + } + addAll(memberEvents) + } + + /** + * Generate the local state events related to the given third party invites, if any. + */ + private fun MutableList.createRoomThreePidEvents() { + createRoomBody.invite3pids.orEmpty().forEach { body -> + val localThirdPartyInviteEvent = createLocalStateEvent( + type = EventType.LOCAL_STATE_ROOM_THIRD_PARTY_INVITE, + content = LocalRoomThirdPartyInviteContent( + isDirect = createRoomBody.isDirect.orFalse(), + membership = Membership.INVITE, + displayName = body.address, + thirdPartyInvite = body.toThreePid() + ).toContent(), + ) + val thirdPartyInviteEvent = createLocalStateEvent( + type = EventType.STATE_ROOM_THIRD_PARTY_INVITE, + content = RoomThirdPartyInviteContent( + displayName = body.address, + keyValidityUrl = null, + publicKey = null, + publicKeys = null + ).toContent(), + ) + add(localThirdPartyInviteEvent) + add(thirdPartyInviteEvent) + } + } + + /** + * Generate the local state event related to the given alias, if any. + */ + fun MutableList.createRoomAliasEvent() { + if (createRoomBody.roomAliasName != null) { + val canonicalAliasContent = createLocalStateEvent( + type = EventType.STATE_ROOM_CANONICAL_ALIAS, + content = RoomCanonicalAliasContent( + canonicalAlias = "${createRoomBody.roomAliasName}:${myUserId.getServerName()}" + ).toContent(), + ) + add(canonicalAliasContent) + } + } + + /** + * Generate the local state events related to the given [CreateRoomPreset]. + * Ref: https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3createroom + */ + private fun MutableList.createRoomPresetEvents() { + val preset = createRoomBody.preset ?: return + + var joinRules: RoomJoinRules? = null + var historyVisibility: RoomHistoryVisibility? = null + var guestAccess: GuestAccess? = null + when (preset) { + CreateRoomPreset.PRESET_PRIVATE_CHAT, + CreateRoomPreset.PRESET_TRUSTED_PRIVATE_CHAT -> { + joinRules = RoomJoinRules.INVITE + historyVisibility = RoomHistoryVisibility.SHARED + guestAccess = GuestAccess.CanJoin + } + CreateRoomPreset.PRESET_PUBLIC_CHAT -> { + joinRules = RoomJoinRules.PUBLIC + historyVisibility = RoomHistoryVisibility.SHARED + guestAccess = GuestAccess.Forbidden + } + } + + add(createLocalStateEvent(EventType.STATE_ROOM_JOIN_RULES, RoomJoinRulesContent(joinRules.value).toContent())) + add(createLocalStateEvent(EventType.STATE_ROOM_HISTORY_VISIBILITY, RoomHistoryVisibilityContent(historyVisibility.value).toContent())) + add(createLocalStateEvent(EventType.STATE_ROOM_GUEST_ACCESS, RoomGuestAccessContent(guestAccess.value).toContent())) + } + + /** + * Generate the local state events related to the given initial states, if any. + * The given initial state events override the potential existing ones of the same type. + */ + private fun MutableList.createRoomInitialStateEvents() { + val initialStates = createRoomBody.initialStates ?: return + + val initialStateEvents = initialStates.map { createLocalStateEvent(it.type, it.content, it.stateKey) } + // Erase existing events of the same type + removeAll { event -> event.type in initialStateEvents.map { it.type } } + // Add the initial state events to the list + addAll(initialStateEvents) + } + + /** + * Generate the local events related to the given room name and topic, if any. + */ + private fun MutableList.createRoomNameAndTopicStateEvents() { + if (createRoomBody.name != null) { + add(createLocalStateEvent(EventType.STATE_ROOM_NAME, RoomNameContent(createRoomBody.name).toContent())) + } + if (createRoomBody.topic != null) { + add(createLocalStateEvent(EventType.STATE_ROOM_TOPIC, RoomTopicContent(createRoomBody.topic).toContent())) + } + } + + /** + * Generate the local events which have not been set and are in that case provided by the server with default values. + * Default events: + * - m.room.history_visibility (https://spec.matrix.org/latest/client-server-api/#server-behaviour-5) + * - m.room.guest_access (https://spec.matrix.org/latest/client-server-api/#mroomguest_access) + */ + private fun MutableList.createRoomDefaultEvents() { + // HistoryVisibility + if (none { it.type == EventType.STATE_ROOM_HISTORY_VISIBILITY }) { + add( + createLocalStateEvent( + type = EventType.STATE_ROOM_HISTORY_VISIBILITY, + content = RoomHistoryVisibilityContent(RoomHistoryVisibility.SHARED.value).toContent(), + ) + ) + } + // GuestAccess + if (none { it.type == EventType.STATE_ROOM_GUEST_ACCESS }) { + add( + createLocalStateEvent( + type = EventType.STATE_ROOM_GUEST_ACCESS, + content = RoomGuestAccessContent(GuestAccess.Forbidden.value).toContent(), + ) + ) + } + } + + /** + * Generate a local state 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 local state event + */ + private fun createLocalStateEvent(type: String?, content: Content?, stateKey: String? = ""): Event { + return Event( + type = type, + senderId = myUserId, + stateKey = stateKey, + content = content, + originServerTs = clock.epochMillis(), + eventId = LocalEcho.createLocalEchoId() + ) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateLocalRoomTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateLocalRoomTask.kt index d57491a4c8..03c2b2a47e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateLocalRoomTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateLocalRoomTask.kt @@ -21,26 +21,15 @@ 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.LocalEcho -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.crypto.DefaultCryptoService 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 @@ -48,6 +37,7 @@ 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.LocalRoomSummaryEntity import org.matrix.android.sdk.internal.database.model.RoomEntity import org.matrix.android.sdk.internal.database.model.RoomMembersLoadStatusType import org.matrix.android.sdk.internal.database.model.RoomSummaryEntity @@ -56,7 +46,6 @@ 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 @@ -70,22 +59,22 @@ import javax.inject.Inject internal interface CreateLocalRoomTask : Task 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 cryptoService: DefaultCryptoService, private val clock: Clock, + private val createLocalRoomStateEventsTask: CreateLocalRoomStateEventsTask, ) : CreateLocalRoomTask { override suspend fun execute(params: CreateRoomParams): String { - val createRoomBody = createRoomBodyBuilder.build(params.withDefault()) + val createRoomBody = createRoomBodyBuilder.build(params) val roomId = RoomLocalEcho.createLocalEchoId() monarchy.awaitTransaction { realm -> createLocalRoomEntity(realm, roomId, createRoomBody) - createLocalRoomSummaryEntity(realm, roomId, createRoomBody) + createLocalRoomSummaryEntity(realm, roomId, params, createRoomBody) } // Wait for room to be created in DB @@ -114,14 +103,29 @@ internal class DefaultCreateLocalRoomTask @Inject constructor( } } - private fun createLocalRoomSummaryEntity(realm: Realm, roomId: String, createRoomBody: CreateRoomBody) { - val otherUserId = createRoomBody.getDirectUserId() - if (otherUserId != null) { - RoomSummaryEntity.getOrCreate(realm, roomId).apply { + private fun createLocalRoomSummaryEntity(realm: Realm, roomId: String, createRoomParams: CreateRoomParams, createRoomBody: CreateRoomBody) { + // Create the room summary entity + val roomSummaryEntity = realm.createObject(roomId).apply { + val otherUserId = createRoomBody.getDirectUserId() + if (otherUserId != null) { isDirect = true directUserId = otherUserId } } + + // Update the createRoomParams from the potential feature preset before saving + createRoomParams.featurePreset?.let { featurePreset -> + featurePreset.updateRoomParams(createRoomParams) + createRoomParams.initialStates.addAll(featurePreset.setupInitialStates().orEmpty()) + } + + // Create a LocalRoomSummaryEntity decorated by the related RoomSummaryEntity and the updated CreateRoomParams + realm.createObject(roomId).also { + it.roomSummaryEntity = roomSummaryEntity + it.createRoomParams = createRoomParams + } + + // Update the RoomSummaryEntity by simulating a fake sync response roomSummaryUpdater.update( realm = realm, roomId = roomId, @@ -150,7 +154,7 @@ internal class DefaultCreateLocalRoomTask @Inject constructor( isLastForward = true } - val eventList = createLocalRoomEvents(createRoomBody) + val eventList = createLocalRoomStateEventsTask.execute(CreateLocalRoomStateEventsTask.Params(createRoomBody)) val roomMemberContentsByUser = HashMap() for (event in eventList) { @@ -169,6 +173,9 @@ internal class DefaultCreateLocalRoomTask @Inject constructor( roomMemberContentsByUser[event.stateKey] = event.getFixedRoomMemberContent() roomMemberEventHandler.handle(realm, roomId, event, false) } + + // Give info to crypto module + cryptoService.onStateEvent(roomId, event) } roomMemberContentsByUser.getOrPut(event.senderId) { @@ -187,81 +194,4 @@ internal class DefaultCreateLocalRoomTask @Inject constructor( 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 { - val myUser = userService.getUser(userId) ?: User(userId) - val invitedUsers = createRoomBody.invitedUserIds.orEmpty() - .mapNotNull { tryOrNull { userService.resolveUser(it) } } - - val createRoomEvent = createLocalEvent( - type = EventType.STATE_ROOM_CREATE, - content = RoomCreateContent( - creator = userId - ).toContent() - ) - val myRoomMemberEvent = createLocalEvent( - type = EventType.STATE_ROOM_MEMBER, - content = RoomMemberContent( - membership = Membership.JOIN, - displayName = myUser.displayName, - avatarUrl = myUser.avatarUrl - ).toContent(), - stateKey = userId - ) - val roomMemberEvents = invitedUsers.map { - createLocalEvent( - 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 { createLocalEvent(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 createLocalEvent(type: String?, content: Content?, stateKey: String? = ""): Event { - return Event( - type = type, - senderId = userId, - stateKey = stateKey, - content = content, - originServerTs = clock.epochMillis(), - eventId = LocalEcho.createLocalEchoId() - ) - } - - /** - * 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 - } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomBody.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomBody.kt index b326c3618c..17e1aba6f6 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomBody.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomBody.kt @@ -22,6 +22,7 @@ import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent import org.matrix.android.sdk.api.session.room.model.RoomDirectoryVisibility import org.matrix.android.sdk.api.session.room.model.create.CreateRoomPreset +import org.matrix.android.sdk.internal.di.MoshiProvider import org.matrix.android.sdk.internal.session.room.membership.threepid.ThreePidInviteBody /** @@ -119,7 +120,13 @@ internal data class CreateRoomBody( */ @Json(name = "room_version") val roomVersion: String? -) +) { + companion object { + fun fromJson(json: String?): CreateRoomBody? { + return json?.let { MoshiProvider.providesMoshi().adapter(CreateRoomBody::class.java).fromJson(it) } + } + } +} /** * Tells if the created room can be a direct chat one. 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..02538a5cc3 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomFromLocalRoomTask.kt @@ -0,0 +1,149 @@ +/* + * 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.kotlin.where +import kotlinx.coroutines.TimeoutCancellationException +import org.matrix.android.sdk.api.extensions.orFalse +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.room.failure.CreateRoomFailure +import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams +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.awaitNotEmptyResult +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.EventEntityFields +import org.matrix.android.sdk.internal.database.model.EventInsertType +import org.matrix.android.sdk.internal.database.model.LocalRoomSummaryEntity +import org.matrix.android.sdk.internal.database.model.LocalRoomSummaryEntityFields +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.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 java.util.concurrent.TimeUnit +import javax.inject.Inject + +/** + * Create a room on the server from a 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 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 { + + private val realmConfiguration + get() = monarchy.realmConfiguration + + override suspend fun execute(params: CreateRoomFromLocalRoomTask.Params): String { + val replacementRoomId = stateEventDataSource.getStateEvent(params.localRoomId, EventType.STATE_ROOM_TOMBSTONE, QueryStringValue.IsEmpty) + ?.content.toModel() + ?.replacementRoomId + + if (replacementRoomId != null) { + return replacementRoomId + } + + var createRoomParams: CreateRoomParams? = null + var isEncrypted = false + monarchy.doWithRealm { realm -> + realm.where() + .equalTo(LocalRoomSummaryEntityFields.ROOM_ID, params.localRoomId) + .findFirst() + ?.let { + createRoomParams = it.createRoomParams + isEncrypted = it.roomSummaryEntity?.isEncrypted.orFalse() + } + } + val roomId = createRoomTask.execute(createRoomParams!!) + + try { + // Wait for all the room events before triggering the replacement room + awaitNotEmptyResult(realmConfiguration, TimeUnit.MINUTES.toMillis(1L)) { realm -> + realm.where(RoomSummaryEntity::class.java) + .equalTo(RoomSummaryEntityFields.ROOM_ID, roomId) + .equalTo(RoomSummaryEntityFields.INVITED_MEMBERS_COUNT, createRoomParams?.invitedUserIds?.size ?: 0) + } + awaitNotEmptyResult(realmConfiguration, TimeUnit.MINUTES.toMillis(1L)) { realm -> + EventEntity.whereRoomId(realm, roomId) + .equalTo(EventEntityFields.TYPE, EventType.STATE_ROOM_HISTORY_VISIBILITY) + } + if (isEncrypted) { + awaitNotEmptyResult(realmConfiguration, TimeUnit.MINUTES.toMillis(1L)) { realm -> + EventEntity.whereRoomId(realm, roomId) + .equalTo(EventEntityFields.TYPE, EventType.STATE_ROOM_ENCRYPTION) + } + } + } catch (exception: TimeoutCancellationException) { + throw CreateRoomFailure.CreatedWithTimeout(roomId) + } + + createTombstoneEvent(params, roomId) + return roomId + } + + /** + * 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 + } + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomTask.kt index d76640573f..e558d34ff9 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateRoomTask.kt @@ -54,8 +54,7 @@ internal class DefaultCreateRoomTask @Inject constructor( private val directChatsHelper: DirectChatsHelper, private val updateUserAccountDataTask: UpdateUserAccountDataTask, private val readMarkersTask: SetReadMarkersTask, - @SessionDatabase - private val realmConfiguration: RealmConfiguration, + @SessionDatabase private val realmConfiguration: RealmConfiguration, private val createRoomBodyBuilder: CreateRoomBodyBuilder, private val globalErrorReceiver: GlobalErrorReceiver, private val clock: Clock, @@ -71,7 +70,6 @@ internal class DefaultCreateRoomTask @Inject constructor( } val createRoomBody = createRoomBodyBuilder.build(params) - val createRoomResponse = try { executeRequest(globalErrorReceiver) { roomAPI.createRoom(createRoomBody) @@ -90,6 +88,7 @@ internal class DefaultCreateRoomTask @Inject constructor( } throw throwable } + val roomId = createRoomResponse.roomId // Wait for room to come back from the sync (but it can maybe be in the DB if the sync response is received before) try { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/LocalRoomThirdPartyInviteContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/LocalRoomThirdPartyInviteContent.kt new file mode 100644 index 0000000000..617ed35326 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/LocalRoomThirdPartyInviteContent.kt @@ -0,0 +1,34 @@ +/* + * 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.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import org.matrix.android.sdk.api.session.identity.ThreePid +import org.matrix.android.sdk.api.session.room.model.Membership + +/** + * Class representing the EventType.LOCAL_STATE_ROOM_THIRD_PARTY_INVITE state event content + * This class is only used to store the third party invite data of a local room. + */ +@JsonClass(generateAdapter = true) +internal data class LocalRoomThirdPartyInviteContent( + @Json(name = "membership") val membership: Membership, + @Json(name = "displayname") val displayName: String? = null, + @Json(name = "is_direct") val isDirect: Boolean = false, + @Json(name = "third_party_invite") val thirdPartyInvite: ThreePid? = null, +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/delete/DeleteLocalRoomTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/delete/DeleteLocalRoomTask.kt index 936c94e520..49951d2da0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/delete/DeleteLocalRoomTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/delete/DeleteLocalRoomTask.kt @@ -21,6 +21,7 @@ import org.matrix.android.sdk.api.session.room.model.localecho.RoomLocalEcho 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.EventEntity +import org.matrix.android.sdk.internal.database.model.LocalRoomSummaryEntity import org.matrix.android.sdk.internal.database.model.RoomEntity import org.matrix.android.sdk.internal.database.model.RoomMemberSummaryEntity import org.matrix.android.sdk.internal.database.model.RoomSummaryEntity @@ -70,6 +71,9 @@ internal class DefaultDeleteLocalRoomTask @Inject constructor( RoomEntity.where(realm, roomId = roomId).findAll() ?.also { Timber.i("## DeleteLocalRoomTask - RoomEntity - delete ${it.size} entries") } ?.deleteAllFromRealm() + LocalRoomSummaryEntity.where(realm, roomId = roomId).findAll() + ?.also { Timber.i("## DeleteLocalRoomTask - LocalRoomSummaryEntity - delete ${it.size} entries") } + ?.deleteAllFromRealm() } } else { Timber.i("## DeleteLocalRoomTask - Failed to remove room with id $roomId: not a local room") diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/membership/threepid/ThreePidInviteBody.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/membership/threepid/ThreePidInviteBody.kt index 3141c052c3..d7b78faea8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/membership/threepid/ThreePidInviteBody.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/membership/threepid/ThreePidInviteBody.kt @@ -18,6 +18,8 @@ package org.matrix.android.sdk.internal.session.room.membership.threepid import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import org.matrix.android.sdk.api.session.identity.ThreePid +import org.matrix.android.sdk.internal.auth.data.ThreePidMedium @JsonClass(generateAdapter = true) internal data class ThreePidInviteBody( @@ -43,3 +45,9 @@ internal data class ThreePidInviteBody( @Json(name = "address") val address: String ) + +internal fun ThreePidInviteBody.toThreePid() = when (medium) { + ThreePidMedium.EMAIL -> ThreePid.Email(address) + ThreePidMedium.MSISDN -> ThreePid.Msisdn(address) + else -> null +} 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/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/create/DefaultCreateLocalRoomStateEventsTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/create/DefaultCreateLocalRoomStateEventsTaskTest.kt new file mode 100644 index 0000000000..1c2cf293b6 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/create/DefaultCreateLocalRoomStateEventsTaskTest.kt @@ -0,0 +1,462 @@ +/* + * Copyright (c) 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 io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldNotBeNull +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.matrix.android.sdk.api.MatrixPatterns.getServerName +import org.matrix.android.sdk.api.crypto.MXCRYPTO_ALGORITHM_MEGOLM +import org.matrix.android.sdk.api.extensions.orFalse +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.content.EncryptionEventContent +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.GuestAccess +import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent +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.RoomHistoryVisibility +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.RoomThirdPartyInviteContent +import org.matrix.android.sdk.api.session.room.model.RoomTopicContent +import org.matrix.android.sdk.api.session.room.model.create.CreateRoomPreset +import org.matrix.android.sdk.api.session.room.model.create.RoomCreateContent +import org.matrix.android.sdk.api.session.room.powerlevels.Role +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.session.profile.ThirdPartyIdentifier.Companion.MEDIUM_EMAIL +import org.matrix.android.sdk.internal.session.profile.ThirdPartyIdentifier.Companion.MEDIUM_MSISDN +import org.matrix.android.sdk.internal.session.room.membership.threepid.ThreePidInviteBody +import org.matrix.android.sdk.internal.session.room.membership.threepid.toThreePid +import org.matrix.android.sdk.internal.util.time.DefaultClock + +private const val MY_USER_ID = "my-user-id" +private const val MY_USER_DISPLAY_NAME = "my-user-display-name" +private const val MY_USER_AVATAR = "my-user-avatar" + +@ExperimentalCoroutinesApi +internal class DefaultCreateLocalRoomStateEventsTaskTest { + + private val clock = DefaultClock() + private val userService = mockk() + + private val defaultCreateLocalRoomStateEventsTask = DefaultCreateLocalRoomStateEventsTask( + myUserId = MY_USER_ID, + userService = userService, + clock = clock + ) + + lateinit var createRoomBody: CreateRoomBody + + @Before + fun setup() { + createRoomBody = mockk { + every { roomVersion } returns null + every { creationContent } returns null + every { roomAliasName } returns null + every { topic } returns null + every { name } returns null + every { powerLevelContentOverride } returns null + every { initialStates } returns null + every { invite3pids } returns null + every { preset } returns null + every { isDirect } returns null + every { invitedUserIds } returns null + } + coEvery { userService.resolveUser(any()) } answers { User(firstArg()) } + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `given a CreateRoomBody when execute then the resulting list of events contains the correct room create state event`() = runTest { + // Given + val aRoomVersion = "a_room_version" + + every { createRoomBody.roomVersion } returns aRoomVersion + + // When + val params = CreateLocalRoomStateEventsTask.Params(createRoomBody) + val result = defaultCreateLocalRoomStateEventsTask.execute(params) + + // Then + val roomCreateEvent = result.find { it.type == EventType.STATE_ROOM_CREATE } + val roomCreateContent = roomCreateEvent?.content.toModel() + + roomCreateContent?.creator shouldBeEqualTo MY_USER_ID + roomCreateContent?.roomVersion shouldBeEqualTo aRoomVersion + } + + @Test + fun `given a CreateRoomBody when execute then the resulting list of events contains the correct name and topic state events`() = runTest { + // Given + val aRoomName = "a_room_name" + val aRoomTopic = "a_room_topic" + + every { createRoomBody.name } returns aRoomName + every { createRoomBody.topic } returns aRoomTopic + + // When + val params = CreateLocalRoomStateEventsTask.Params(createRoomBody) + val result = defaultCreateLocalRoomStateEventsTask.execute(params) + + // Then + val roomNameEvent = result.find { it.type == EventType.STATE_ROOM_NAME } + val roomTopicEvent = result.find { it.type == EventType.STATE_ROOM_TOPIC } + + roomNameEvent?.content.toModel()?.name shouldBeEqualTo aRoomName + roomTopicEvent?.content.toModel()?.topic shouldBeEqualTo aRoomTopic + } + + @Test + fun `given a CreateRoomBody when execute then the resulting list of events contains the correct room member events`() = runTest { + // Given + data class RoomMember(val user: User, val membership: Membership) + + val aRoomMemberList: List = listOf( + RoomMember(User(MY_USER_ID, MY_USER_DISPLAY_NAME, MY_USER_AVATAR), Membership.JOIN), + RoomMember(User("userA_id", "userA_display_name", "userA_avatar"), Membership.INVITE), + RoomMember(User("userB_id", "userB_display_name", "userB_avatar"), Membership.INVITE) + ) + + every { createRoomBody.invitedUserIds } returns aRoomMemberList.filter { it.membership == Membership.INVITE }.map { it.user.userId } + coEvery { userService.resolveUser(any()) } answers { + aRoomMemberList.map { it.user }.find { it.userId == firstArg() } ?: User(firstArg()) + } + + // When + val params = CreateLocalRoomStateEventsTask.Params(createRoomBody) + val result = defaultCreateLocalRoomStateEventsTask.execute(params) + + // Then + val roomMemberEvents = result.filter { it.type == EventType.STATE_ROOM_MEMBER } + + roomMemberEvents.map { it.stateKey } shouldBeEqualTo aRoomMemberList.map { it.user.userId } + roomMemberEvents.forEach { event -> + val roomMemberContent = event.content.toModel() + val roomMember = aRoomMemberList.find { it.user.userId == event.stateKey } + + roomMember.shouldNotBeNull() + roomMemberContent?.avatarUrl shouldBeEqualTo roomMember.user.avatarUrl + roomMemberContent?.displayName shouldBeEqualTo roomMember.user.displayName + roomMemberContent?.membership shouldBeEqualTo roomMember.membership + } + } + + @Test + fun `given a CreateRoomBody when execute then the resulting list of events contains the correct power levels event`() = runTest { + // Given + val aPowerLevelsContent = PowerLevelsContent( + ban = 1, + kick = 2, + invite = 3, + redact = 4, + eventsDefault = 5, + events = null, + usersDefault = 6, + users = null, + stateDefault = 7, + notifications = null + ) + + every { createRoomBody.powerLevelContentOverride } returns aPowerLevelsContent + + // When + val params = CreateLocalRoomStateEventsTask.Params(createRoomBody) + val result = defaultCreateLocalRoomStateEventsTask.execute(params) + + // Then + val roomPowerLevelsEvent = result.find { it.type == EventType.STATE_ROOM_POWER_LEVELS } + roomPowerLevelsEvent?.content.toModel() shouldBeEqualTo aPowerLevelsContent + } + + @Test + fun `given a CreateRoomBody when execute then the resulting list of events contains the correct canonical alias event`() = runTest { + // Given + val aRoomAlias = "a_room_alias" + val expectedCanonicalAlias = "$aRoomAlias:${MY_USER_ID.getServerName()}" + + every { createRoomBody.roomAliasName } returns aRoomAlias + + // When + val params = CreateLocalRoomStateEventsTask.Params(createRoomBody) + val result = defaultCreateLocalRoomStateEventsTask.execute(params) + + // Then + val roomPowerLevelsEvent = result.find { it.type == EventType.STATE_ROOM_CANONICAL_ALIAS } + roomPowerLevelsEvent?.content.toModel()?.canonicalAlias shouldBeEqualTo expectedCanonicalAlias + } + + @Test + fun `given a CreateRoomBody when execute then the resulting list of events contains the correct preset related events`() = runTest { + data class ExpectedResult(val joinRules: RoomJoinRules, val historyVisibility: RoomHistoryVisibility, val guestAccess: GuestAccess) + data class Case(val preset: CreateRoomPreset, val expectedResult: ExpectedResult) + + CreateRoomPreset.values().forEach { aRoomPreset -> + // Given + val case = when (aRoomPreset) { + CreateRoomPreset.PRESET_PRIVATE_CHAT -> Case( + CreateRoomPreset.PRESET_PRIVATE_CHAT, + ExpectedResult(RoomJoinRules.INVITE, RoomHistoryVisibility.SHARED, GuestAccess.CanJoin) + ) + CreateRoomPreset.PRESET_TRUSTED_PRIVATE_CHAT -> Case( + CreateRoomPreset.PRESET_TRUSTED_PRIVATE_CHAT, + ExpectedResult(RoomJoinRules.INVITE, RoomHistoryVisibility.SHARED, GuestAccess.CanJoin) + ) + CreateRoomPreset.PRESET_PUBLIC_CHAT -> Case( + CreateRoomPreset.PRESET_PUBLIC_CHAT, + ExpectedResult(RoomJoinRules.PUBLIC, RoomHistoryVisibility.SHARED, GuestAccess.Forbidden) + ) + } + every { createRoomBody.preset } returns case.preset + + // When + val params = CreateLocalRoomStateEventsTask.Params(createRoomBody) + val result = defaultCreateLocalRoomStateEventsTask.execute(params) + + // Then + result.find { it.type == EventType.STATE_ROOM_JOIN_RULES } + ?.content.toModel() + ?.joinRules shouldBeEqualTo case.expectedResult.joinRules + result.find { it.type == EventType.STATE_ROOM_HISTORY_VISIBILITY } + ?.content.toModel() + ?.historyVisibility shouldBeEqualTo case.expectedResult.historyVisibility + result.find { it.type == EventType.STATE_ROOM_GUEST_ACCESS } + ?.content.toModel() + ?.guestAccess shouldBeEqualTo case.expectedResult.guestAccess + } + } + + @Test + fun `given a CreateRoomBody when execute then the resulting list of events contains the initial state events`() = runTest { + // Given + val aListOfInitialStateEvents = listOf( + Event( + type = EventType.STATE_ROOM_ENCRYPTION, + stateKey = "", + content = EncryptionEventContent(MXCRYPTO_ALGORITHM_MEGOLM).toContent() + ), + Event( + type = "a_custom_type", + content = mapOf("a_custom_map_to_integer" to 42), + stateKey = "a_state_key" + ), + Event( + type = "another_custom_type", + content = mapOf("a_custom_map_to_boolean" to false), + stateKey = "another_state_key" + ) + ) + + every { createRoomBody.initialStates } returns aListOfInitialStateEvents + + // When + val params = CreateLocalRoomStateEventsTask.Params(createRoomBody) + val result = defaultCreateLocalRoomStateEventsTask.execute(params) + + // Then + aListOfInitialStateEvents.forEach { expected -> + val found = result.find { it.type == expected.type } + found.shouldNotBeNull() + found.content shouldBeEqualTo expected.content + found.stateKey shouldBeEqualTo expected.stateKey + } + } + + @Test + fun `given a CreateRoomBody when execute then the resulting list of events contains the correct third party invite events`() = runTest { + // Given + val aListOfThreePids = listOf( + ThreePid.Email("bob@matrix.org"), + ThreePid.Msisdn("+11111111111"), + ThreePid.Email("alice@matrix.org"), + ThreePid.Msisdn("+22222222222"), + ) + val aListOf3pids = aListOfThreePids.mapIndexed { index, threePid -> + ThreePidInviteBody( + idServer = "an_id_server_$index", + idAccessToken = "an_id_access_token_$index", + medium = when (threePid) { + is ThreePid.Email -> MEDIUM_EMAIL + is ThreePid.Msisdn -> MEDIUM_MSISDN + }, + address = threePid.value + ) + } + every { createRoomBody.invite3pids } returns aListOf3pids + + // When + val params = CreateLocalRoomStateEventsTask.Params(createRoomBody) + val result = defaultCreateLocalRoomStateEventsTask.execute(params) + + // Then + val thirdPartyInviteEvents = result.filter { it.type == EventType.STATE_ROOM_THIRD_PARTY_INVITE } + val thirdPartyInviteContents = thirdPartyInviteEvents.map { it.content.toModel() } + val localThirdPartyInviteEvents = result.filter { it.type == EventType.LOCAL_STATE_ROOM_THIRD_PARTY_INVITE } + val localThirdPartyInviteContents = localThirdPartyInviteEvents.map { it.content.toModel() } + + thirdPartyInviteEvents.size shouldBeEqualTo aListOf3pids.size + localThirdPartyInviteEvents.size shouldBeEqualTo aListOf3pids.size + + aListOf3pids.forEach { expected -> + thirdPartyInviteContents.find { it?.displayName == expected.address }.shouldNotBeNull() + + val localThirdPartyInviteContent = localThirdPartyInviteContents.find { it?.thirdPartyInvite == expected.toThreePid() } + localThirdPartyInviteContent.shouldNotBeNull() + localThirdPartyInviteContent.membership shouldBeEqualTo Membership.INVITE + localThirdPartyInviteContent.isDirect shouldBeEqualTo createRoomBody.isDirect.orFalse() + localThirdPartyInviteContent.displayName shouldBeEqualTo expected.address + } + } + + @Test + fun `given a CreateRoomBody with default values when execute then the resulting list of events is correct`() = runTest { + // Given + // map of expected event types to occurrences + val expectedEventTypes = mapOf( + EventType.STATE_ROOM_CREATE to 1, + EventType.STATE_ROOM_POWER_LEVELS to 1, + EventType.STATE_ROOM_MEMBER to 1, + EventType.STATE_ROOM_GUEST_ACCESS to 1, + EventType.STATE_ROOM_HISTORY_VISIBILITY to 1, + ) + coEvery { userService.resolveUser(any()) } answers { + if (firstArg() == MY_USER_ID) User(MY_USER_ID, MY_USER_DISPLAY_NAME, MY_USER_AVATAR) else User(firstArg()) + } + + // When + val params = CreateLocalRoomStateEventsTask.Params(createRoomBody) + val result = defaultCreateLocalRoomStateEventsTask.execute(params) + + // Then + result.size shouldBeEqualTo expectedEventTypes.values.sum() + result.map { it.type }.toSet() shouldBeEqualTo expectedEventTypes.keys + + // Room create + result.find { it.type == EventType.STATE_ROOM_CREATE }.shouldNotBeNull() + // Room member + result.singleOrNull { it.type == EventType.STATE_ROOM_MEMBER }?.stateKey shouldBeEqualTo MY_USER_ID + // Power levels + val powerLevelsContent = result.find { it.type == EventType.STATE_ROOM_POWER_LEVELS }?.content.toModel() + powerLevelsContent.shouldNotBeNull() + powerLevelsContent.ban shouldBeEqualTo Role.Moderator.value + powerLevelsContent.kick shouldBeEqualTo Role.Moderator.value + powerLevelsContent.invite shouldBeEqualTo Role.Moderator.value + powerLevelsContent.redact shouldBeEqualTo Role.Moderator.value + powerLevelsContent.eventsDefault shouldBeEqualTo Role.Default.value + powerLevelsContent.usersDefault shouldBeEqualTo Role.Default.value + powerLevelsContent.stateDefault shouldBeEqualTo Role.Moderator.value + // Guest access + result.find { it.type == EventType.STATE_ROOM_GUEST_ACCESS } + ?.content.toModel()?.guestAccess shouldBeEqualTo GuestAccess.Forbidden + // History visibility + result.find { it.type == EventType.STATE_ROOM_HISTORY_VISIBILITY } + ?.content.toModel()?.historyVisibility shouldBeEqualTo RoomHistoryVisibility.SHARED + } + + @Test + fun `given a CreateRoomBody when execute then the resulting list of events is correctly ordered with the right values`() = runTest { + // Given + val expectedIsDirect = true + val expectedHistoryVisibility = RoomHistoryVisibility.WORLD_READABLE + + every { createRoomBody.roomVersion } returns "a_room_version" + every { createRoomBody.roomAliasName } returns "a_room_alias_name" + every { createRoomBody.name } returns "a_name" + every { createRoomBody.topic } returns "a_topic" + every { createRoomBody.powerLevelContentOverride } returns PowerLevelsContent( + ban = 1, + kick = 2, + invite = 3, + redact = 4, + eventsDefault = 5, + events = null, + usersDefault = 6, + users = null, + stateDefault = 7, + notifications = null + ) + every { createRoomBody.invite3pids } returns listOf( + ThreePidInviteBody( + idServer = "an_id_server", + idAccessToken = "an_id_access_token", + medium = MEDIUM_EMAIL, + address = "an_email@example.org" + ) + ) + every { createRoomBody.preset } returns CreateRoomPreset.PRESET_TRUSTED_PRIVATE_CHAT + every { createRoomBody.initialStates } returns listOf( + Event(type = "a_custom_type", stateKey = ""), + // override the value from the preset + Event( + type = EventType.STATE_ROOM_HISTORY_VISIBILITY, + stateKey = "", + content = RoomHistoryVisibilityContent(expectedHistoryVisibility.value).toContent() + ) + ) + every { createRoomBody.isDirect } returns expectedIsDirect + every { createRoomBody.invitedUserIds } returns listOf("a_user_id") + + val orderedExpectedEventType = listOf( + EventType.STATE_ROOM_CREATE, + EventType.STATE_ROOM_MEMBER, + EventType.STATE_ROOM_POWER_LEVELS, + EventType.STATE_ROOM_CANONICAL_ALIAS, + EventType.STATE_ROOM_JOIN_RULES, + EventType.STATE_ROOM_GUEST_ACCESS, + "a_custom_type", + EventType.STATE_ROOM_HISTORY_VISIBILITY, + EventType.STATE_ROOM_NAME, + EventType.STATE_ROOM_TOPIC, + EventType.STATE_ROOM_MEMBER, + EventType.LOCAL_STATE_ROOM_THIRD_PARTY_INVITE, + EventType.STATE_ROOM_THIRD_PARTY_INVITE, + ) + + // When + val params = CreateLocalRoomStateEventsTask.Params(createRoomBody) + val result = defaultCreateLocalRoomStateEventsTask.execute(params) + + // Then + result.map { it.type } shouldBeEqualTo orderedExpectedEventType + result.find { it.type == EventType.STATE_ROOM_HISTORY_VISIBILITY } + ?.content.toModel()?.historyVisibility shouldBeEqualTo expectedHistoryVisibility + result.lastOrNull { it.type == EventType.STATE_ROOM_MEMBER } + ?.content.toModel()?.isDirect shouldBeEqualTo expectedIsDirect + result.lastOrNull { it.type == EventType.LOCAL_STATE_ROOM_THIRD_PARTY_INVITE } + ?.content.toModel()?.isDirect shouldBeEqualTo expectedIsDirect + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/create/DefaultCreateRoomFromLocalRoomTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/create/DefaultCreateRoomFromLocalRoomTaskTest.kt new file mode 100644 index 0000000000..d3732363b5 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/create/DefaultCreateRoomFromLocalRoomTaskTest.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 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 io.mockk.coEvery +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.realm.kotlin.where +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.After +import org.junit.Before +import org.junit.Test +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.room.model.create.CreateRoomParams +import org.matrix.android.sdk.api.session.room.model.tombstone.RoomTombstoneContent +import org.matrix.android.sdk.internal.database.awaitNotEmptyResult +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.LocalRoomSummaryEntity +import org.matrix.android.sdk.internal.database.model.LocalRoomSummaryEntityFields +import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore +import org.matrix.android.sdk.internal.database.query.getOrCreate +import org.matrix.android.sdk.internal.util.time.DefaultClock +import org.matrix.android.sdk.test.fakes.FakeMonarchy +import org.matrix.android.sdk.test.fakes.FakeStateEventDataSource + +private const val A_LOCAL_ROOM_ID = "local.a-local-room-id" +private const val AN_EXISTING_ROOM_ID = "an-existing-room-id" +private const val A_ROOM_ID = "a-room-id" +private const val MY_USER_ID = "my-user-id" + +@ExperimentalCoroutinesApi +internal class DefaultCreateRoomFromLocalRoomTaskTest { + + private val fakeMonarchy = FakeMonarchy() + private val clock = DefaultClock() + private val createRoomTask = mockk() + private val fakeStateEventDataSource = FakeStateEventDataSource() + + private val defaultCreateRoomFromLocalRoomTask = DefaultCreateRoomFromLocalRoomTask( + userId = MY_USER_ID, + monarchy = fakeMonarchy.instance, + createRoomTask = createRoomTask, + stateEventDataSource = fakeStateEventDataSource.instance, + clock = clock + ) + + @Before + fun setup() { + mockkStatic("org.matrix.android.sdk.internal.database.RealmQueryLatchKt") + coJustRun { awaitNotEmptyResult(realmConfiguration = any(), timeoutMillis = any(), builder = any()) } + + mockkStatic("org.matrix.android.sdk.internal.database.query.EventEntityQueriesKt") + coEvery { any().copyToRealmOrIgnore(fakeMonarchy.fakeRealm.instance, any()) } answers { firstArg() } + + mockkStatic("org.matrix.android.sdk.internal.database.query.CurrentStateEventEntityQueriesKt") + every { CurrentStateEventEntity.getOrCreate(fakeMonarchy.fakeRealm.instance, any(), any(), any()) } answers { + CurrentStateEventEntity(roomId = arg(2), stateKey = arg(3), type = arg(4)) + } + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `given a local room id when execute then the existing room id is kept`() = runTest { + // Given + givenATombstoneEvent( + Event( + roomId = A_LOCAL_ROOM_ID, + type = EventType.STATE_ROOM_TOMBSTONE, + stateKey = "", + content = RoomTombstoneContent(replacementRoomId = AN_EXISTING_ROOM_ID).toContent() + ) + ) + + // When + val params = CreateRoomFromLocalRoomTask.Params(A_LOCAL_ROOM_ID) + val result = defaultCreateRoomFromLocalRoomTask.execute(params) + + // Then + verifyTombstoneEvent(AN_EXISTING_ROOM_ID) + result shouldBeEqualTo AN_EXISTING_ROOM_ID + } + + @Test + fun `given a local room id when execute then it is correctly executed`() = runTest { + // Given + val aCreateRoomParams = mockk() + val aLocalRoomSummaryEntity = mockk { + every { roomSummaryEntity } returns mockk(relaxed = true) + every { createRoomParams } returns aCreateRoomParams + } + givenATombstoneEvent(null) + givenALocalRoomSummaryEntity(aLocalRoomSummaryEntity) + + coEvery { createRoomTask.execute(any()) } returns A_ROOM_ID + + // When + val params = CreateRoomFromLocalRoomTask.Params(A_LOCAL_ROOM_ID) + val result = defaultCreateRoomFromLocalRoomTask.execute(params) + + // Then + verifyTombstoneEvent(null) + // CreateRoomTask has been called with the initial CreateRoomParams + coVerify { createRoomTask.execute(aCreateRoomParams) } + // The resulting roomId matches the roomId returned by the createRoomTask + result shouldBeEqualTo A_ROOM_ID + // A tombstone state event has been created + coVerify { CurrentStateEventEntity.getOrCreate(realm = any(), roomId = A_LOCAL_ROOM_ID, stateKey = any(), type = EventType.STATE_ROOM_TOMBSTONE) } + } + + private fun givenATombstoneEvent(event: Event?) { + fakeStateEventDataSource.givenGetStateEventReturns(event) + } + + private fun givenALocalRoomSummaryEntity(localRoomSummaryEntity: LocalRoomSummaryEntity) { + every { + fakeMonarchy.fakeRealm.instance + .where() + .equalTo(LocalRoomSummaryEntityFields.ROOM_ID, A_LOCAL_ROOM_ID) + .findFirst() + } returns localRoomSummaryEntity + } + + private fun verifyTombstoneEvent(expectedRoomId: String?) { + fakeStateEventDataSource.verifyGetStateEvent(A_LOCAL_ROOM_ID, EventType.STATE_ROOM_TOMBSTONE, QueryStringValue.IsEmpty) + fakeStateEventDataSource.instance.getStateEvent(A_LOCAL_ROOM_ID, EventType.STATE_ROOM_TOMBSTONE, QueryStringValue.IsEmpty) + ?.content.toModel() + ?.replacementRoomId shouldBeEqualTo expectedRoomId + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultGetActiveBeaconInfoForUserTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultGetActiveBeaconInfoForUserTaskTest.kt index 588bfaa979..d51ed77399 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultGetActiveBeaconInfoForUserTaskTest.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultGetActiveBeaconInfoForUserTaskTest.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBeEqualTo import org.junit.After import org.junit.Test +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 @@ -69,7 +70,7 @@ class DefaultGetActiveBeaconInfoForUserTaskTest { fakeStateEventDataSource.verifyGetStateEvent( roomId = params.roomId, eventType = EventType.STATE_ROOM_BEACON_INFO.first(), - stateKey = A_USER_ID + stateKey = QueryStringValue.Equals(A_USER_ID) ) } } diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeMonarchy.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeMonarchy.kt index d77084fe3b..2d501f12af 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeMonarchy.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeMonarchy.kt @@ -33,7 +33,7 @@ import org.matrix.android.sdk.internal.util.awaitTransaction internal class FakeMonarchy { val instance = mockk() - private val fakeRealm = FakeRealm() + val fakeRealm = FakeRealm() init { mockkStatic("org.matrix.android.sdk.internal.util.MonarchyKt") @@ -42,6 +42,12 @@ internal class FakeMonarchy { } coAnswers { secondArg Any>().invoke(fakeRealm.instance) } + coEvery { + instance.doWithRealm(any()) + } coAnswers { + firstArg().doWithRealm(fakeRealm.instance) + } + every { instance.realmConfiguration } returns mockk() } inline fun givenWhere(): RealmQuery { diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeStateEventDataSource.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeStateEventDataSource.kt index ca03316fa7..ebb2a1d7a0 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeStateEventDataSource.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeStateEventDataSource.kt @@ -19,7 +19,7 @@ package org.matrix.android.sdk.test.fakes import io.mockk.every import io.mockk.mockk import io.mockk.verify -import org.matrix.android.sdk.api.query.QueryStringValue +import org.matrix.android.sdk.api.query.QueryStateEventValue import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.internal.session.room.state.StateEventDataSource @@ -37,12 +37,12 @@ internal class FakeStateEventDataSource { } returns event } - fun verifyGetStateEvent(roomId: String, eventType: String, stateKey: String) { + fun verifyGetStateEvent(roomId: String, eventType: String, stateKey: QueryStateEventValue) { verify { instance.getStateEvent( roomId = roomId, eventType = eventType, - stateKey = QueryStringValue.Equals(stateKey) + stateKey = stateKey ) } } diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index bed0b618d0..ea62aa1b58 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -349,6 +349,7 @@ + diff --git a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt index 6da47c4f7d..b21b4778e3 100644 --- a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt @@ -52,6 +52,7 @@ import im.vector.app.features.home.room.detail.timeline.reactions.ViewReactionsV import im.vector.app.features.home.room.detail.upgrade.MigrateRoomViewModel import im.vector.app.features.home.room.list.RoomListViewModel import im.vector.app.features.home.room.list.home.HomeRoomListViewModel +import im.vector.app.features.home.room.list.home.invites.InvitesViewModel import im.vector.app.features.homeserver.HomeServerCapabilitiesViewModel import im.vector.app.features.invite.InviteUsersToRoomViewModel import im.vector.app.features.location.LocationSharingViewModel @@ -618,4 +619,9 @@ interface MavericksViewModelModule { @IntoMap @MavericksViewModelKey(HomeRoomListViewModel::class) fun homeRoomListViewModel(factory: HomeRoomListViewModel.Factory): MavericksAssistedViewModelFactory<*, *> + + @Binds + @IntoMap + @MavericksViewModelKey(InvitesViewModel::class) + fun invitesViewModel(factory: InvitesViewModel.Factory): MavericksAssistedViewModelFactory<*, *> } 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 diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt index e6765bf35a..d22b649b36 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt @@ -229,8 +229,9 @@ class TimelineEventVisibilityHelper @Inject constructor( // Hide fake events for local rooms if (RoomLocalEcho.isLocalEchoId(roomId) && - root.getClearType() == EventType.STATE_ROOM_MEMBER || - root.getClearType() == EventType.STATE_ROOM_HISTORY_VISIBILITY) { + (root.getClearType() == EventType.STATE_ROOM_MEMBER || + root.getClearType() == EventType.STATE_ROOM_HISTORY_VISIBILITY || + root.getClearType() == EventType.STATE_ROOM_THIRD_PARTY_INVITE)) { return true } diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListFragment.kt index 8fedfef323..3e8c2b5dcd 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListFragment.kt @@ -16,6 +16,7 @@ package im.vector.app.features.home.room.list.home +import android.content.Intent import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -48,6 +49,8 @@ import im.vector.app.features.home.room.list.actions.RoomListSharedAction import im.vector.app.features.home.room.list.actions.RoomListSharedActionViewModel import im.vector.app.features.home.room.list.home.filter.HomeFilteredRoomsController import im.vector.app.features.home.room.list.home.filter.HomeRoomFilter +import im.vector.app.features.home.room.list.home.invites.InvitesActivity +import im.vector.app.features.home.room.list.home.invites.InvitesCounterController import im.vector.app.features.home.room.list.home.recent.RecentRoomCarouselController import im.vector.app.features.spaces.SpaceListBottomSheet import kotlinx.coroutines.flow.launchIn @@ -66,6 +69,7 @@ class HomeRoomListFragment : @Inject lateinit var roomSummaryItemFactory: RoomSummaryItemFactory @Inject lateinit var userPreferencesProvider: UserPreferencesProvider @Inject lateinit var recentRoomCarouselController: RecentRoomCarouselController + @Inject lateinit var invitesCounterController: InvitesCounterController private val roomListViewModel: HomeRoomListViewModel by fragmentViewModel() private lateinit var sharedQuickActionsViewModel: RoomListQuickActionsSharedActionViewModel @@ -266,9 +270,19 @@ class HomeRoomListFragment : controller.submitList(list) } }.adapter + is HomeRoomSection.InvitesCountData -> invitesCounterController.also { controller -> + controller.clickListener = ::onInvitesCounterClicked + section.count.observe(viewLifecycleOwner) { count -> + controller.submitData(count) + } + }.adapter } } + private fun onInvitesCounterClicked() { + startActivity(Intent(activity, InvitesActivity::class.java)) + } + private fun onRoomFilterChanged(filter: HomeRoomFilter) { roomListViewModel.handle(HomeRoomListAction.ChangeRoomFilter(filter)) } @@ -285,6 +299,7 @@ class HomeRoomListFragment : override fun onDestroyView() { views.roomListView.cleanup() recentRoomCarouselController.listener = null + invitesCounterController.clickListener = null super.onDestroyView() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListViewModel.kt index 711ba0c10a..5ecf9d6d96 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomListViewModel.kt @@ -16,6 +16,7 @@ package im.vector.app.features.home.room.list.home +import androidx.lifecycle.map import androidx.paging.PagedList import arrow.core.toOption import com.airbnb.mvrx.MavericksViewModelFactory @@ -100,9 +101,9 @@ class HomeRoomListViewModel @AssistedInject constructor( private fun configureSections() = viewModelScope.launch { val newSections = mutableSetOf() + newSections.add(getInvitesCountSection()) val areSettingsEnabled = preferencesStore.areRecentsEnabledFlow.first() - if (areSettingsEnabled) { newSections.add(getRecentRoomsSection()) } @@ -127,6 +128,19 @@ class HomeRoomListViewModel @AssistedInject constructor( ) } + private fun getInvitesCountSection(): HomeRoomSection.InvitesCountData { + val builder = RoomSummaryQueryParams.Builder().also { + it.memberships = listOf(Membership.INVITE) + } + + val liveCount = session.roomService().getRoomSummariesLive( + builder.build(), + RoomSortOrder.ACTIVITY + ).map { it.count() } + + return HomeRoomSection.InvitesCountData(liveCount) + } + private suspend fun getFilteredRoomsSection(): HomeRoomSection.RoomSummaryData { val builder = RoomSummaryQueryParams.Builder().also { it.memberships = listOf(Membership.JOIN) diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomSection.kt b/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomSection.kt index 74ec46d6b7..29df594d06 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomSection.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/home/HomeRoomSection.kt @@ -32,4 +32,8 @@ sealed class HomeRoomSection { data class RecentRoomsData( val list: LiveData> ) : HomeRoomSection() + + data class InvitesCountData( + val count: LiveData + ) : HomeRoomSection() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InviteCounterItem.kt b/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InviteCounterItem.kt new file mode 100644 index 0000000000..4bc292be27 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InviteCounterItem.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.home.room.list.home.invites + +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.app.R +import im.vector.app.core.epoxy.ClickListener +import im.vector.app.core.epoxy.VectorEpoxyHolder +import im.vector.app.core.epoxy.VectorEpoxyModel +import im.vector.app.features.home.room.list.UnreadCounterBadgeView + +@EpoxyModelClass +abstract class InviteCounterItem : VectorEpoxyModel(R.layout.item_invites_count) { + + @EpoxyAttribute var invitesCount: Int = 0 + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var listener: ClickListener? = null + + override fun bind(holder: Holder) { + super.bind(holder) + holder.view.setOnClickListener(listener) + holder.unreadCounterBadgeView.render(UnreadCounterBadgeView.State(invitesCount, true)) + } + + class Holder : VectorEpoxyHolder() { + val unreadCounterBadgeView by bind(R.id.invites_count_badge) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesAction.kt b/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesAction.kt new file mode 100644 index 0000000000..ed6ed23c9d --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesAction.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.home.room.list.home.invites + +import im.vector.app.core.platform.VectorViewModelAction +import org.matrix.android.sdk.api.session.room.model.RoomSummary + +sealed class InvitesAction : VectorViewModelAction { + data class AcceptInvitation(val roomSummary: RoomSummary) : InvitesAction() + data class RejectInvitation(val roomSummary: RoomSummary) : InvitesAction() +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesActivity.kt b/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesActivity.kt new file mode 100644 index 0000000000..b590caab42 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesActivity.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.home.room.list.home.invites + +import dagger.hilt.android.AndroidEntryPoint +import im.vector.app.core.extensions.addFragment +import im.vector.app.core.platform.VectorBaseActivity +import im.vector.app.databinding.ActivitySimpleBinding + +@AndroidEntryPoint +class InvitesActivity : VectorBaseActivity() { + + override fun getBinding() = ActivitySimpleBinding.inflate(layoutInflater) + + override fun initUiAndData() { + if (isFirstCreation()) { + addFragment(views.simpleFragmentContainer, InvitesFragment::class.java) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesController.kt b/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesController.kt new file mode 100644 index 0000000000..1511b97c3c --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesController.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.home.room.list.home.invites + +import com.airbnb.epoxy.EpoxyModel +import com.airbnb.epoxy.paging.PagedListEpoxyController +import im.vector.app.core.utils.createUIHandler +import im.vector.app.features.home.RoomListDisplayMode +import im.vector.app.features.home.room.list.RoomListListener +import im.vector.app.features.home.room.list.RoomSummaryItemFactory +import im.vector.app.features.home.room.list.RoomSummaryItemPlaceHolder_ +import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState +import org.matrix.android.sdk.api.session.room.model.RoomSummary +import javax.inject.Inject + +class InvitesController @Inject constructor( + private val roomSummaryItemFactory: RoomSummaryItemFactory, +) : PagedListEpoxyController( + // Important it must match the PageList builder notify Looper + modelBuildingHandler = createUIHandler() +) { + + var roomChangeMembershipStates: Map? = null + set(value) { + field = value + requestForcedModelBuild() + } + + var listener: RoomListListener? = null + + override fun buildItemModel(currentPosition: Int, item: RoomSummary?): EpoxyModel<*> { + item ?: return RoomSummaryItemPlaceHolder_().apply { id(currentPosition) } + return roomSummaryItemFactory.create(item, roomChangeMembershipStates.orEmpty(), emptySet(), RoomListDisplayMode.ROOMS, listener) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesCounterController.kt b/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesCounterController.kt new file mode 100644 index 0000000000..82a31d30a9 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesCounterController.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.home.room.list.home.invites + +import com.airbnb.epoxy.EpoxyController +import im.vector.app.core.resources.StringProvider +import javax.inject.Inject + +class InvitesCounterController @Inject constructor( + val stringProvider: StringProvider +) : EpoxyController() { + + private var count = 0 + var clickListener: (() -> Unit)? = null + + override fun buildModels() { + val host = this + if (count != 0) { + inviteCounterItem { + id("invites_counter") + invitesCount(host.count) + listener { host.clickListener?.invoke() } + } + } + } + + fun submitData(count: Int?) { + this.count = count ?: 0 + requestModelBuild() + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesFragment.kt new file mode 100644 index 0000000000..74b46cec33 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesFragment.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.home.room.list.home.invites + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.airbnb.mvrx.fragmentViewModel +import com.airbnb.mvrx.withState +import dagger.hilt.android.AndroidEntryPoint +import im.vector.app.core.extensions.configureWith +import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.databinding.FragmentInvitesBinding +import im.vector.app.features.analytics.plan.ViewRoom +import im.vector.app.features.home.room.list.RoomListListener +import im.vector.app.features.notifications.NotificationDrawerManager +import org.matrix.android.sdk.api.session.room.model.RoomSummary +import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo +import javax.inject.Inject + +@AndroidEntryPoint +class InvitesFragment : VectorBaseFragment(), RoomListListener { + + @Inject lateinit var controller: InvitesController + @Inject lateinit var notificationDrawerManager: NotificationDrawerManager + + private val viewModel by fragmentViewModel(InvitesViewModel::class) + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentInvitesBinding { + return FragmentInvitesBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupToolbar(views.invitesToolbar) + .allowBack() + + views.invitesRecycler.configureWith(controller) + controller.listener = this + + viewModel.onEach(InvitesViewState::roomMembershipChanges) { + controller.roomChangeMembershipStates = it + } + + viewModel.observeViewEvents { + when (it) { + is InvitesViewEvents.Failure -> showFailure(it.throwable) + is InvitesViewEvents.OpenRoom -> handleOpenRoom(it.roomSummary, it.shouldCloseInviteView) + InvitesViewEvents.Close -> handleClose() + } + } + } + + private fun handleClose() { + requireActivity().finish() + } + + private fun handleOpenRoom(roomSummary: RoomSummary, shouldCloseInviteView: Boolean) { + navigator.openRoom( + context = requireActivity(), + roomId = roomSummary.roomId, + isInviteAlreadyAccepted = true, + trigger = ViewRoom.Trigger.RoomList // #6508 + ) + if (shouldCloseInviteView) { + requireActivity().finish() + } + } + + override fun invalidate(): Unit = withState(viewModel) { state -> + super.invalidate() + + state.pagedList?.observe(viewLifecycleOwner) { list -> + controller.submitList(list) + } + } + + override fun onRejectRoomInvitation(room: RoomSummary) { + notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(room.roomId) } + viewModel.handle(InvitesAction.RejectInvitation(room)) + } + + override fun onAcceptRoomInvitation(room: RoomSummary) { + notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(room.roomId) } + viewModel.handle(InvitesAction.AcceptInvitation(room)) + } + + override fun onJoinSuggestedRoom(room: SpaceChildInfo) = Unit + + override fun onSuggestedRoomClicked(room: SpaceChildInfo) = Unit + + override fun onRoomClicked(room: RoomSummary) = Unit + + override fun onRoomLongClicked(room: RoomSummary): Boolean = false +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesViewEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesViewEvents.kt new file mode 100644 index 0000000000..d68577cf95 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesViewEvents.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.home.room.list.home.invites + +import im.vector.app.core.platform.VectorViewEvents +import org.matrix.android.sdk.api.session.room.model.RoomSummary + +sealed class InvitesViewEvents : VectorViewEvents { + data class Failure(val throwable: Throwable) : InvitesViewEvents() + data class OpenRoom(val roomSummary: RoomSummary, val shouldCloseInviteView: Boolean) : InvitesViewEvents() + object Close : InvitesViewEvents() +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesViewModel.kt new file mode 100644 index 0000000000..b0d854be66 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesViewModel.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.home.room.list.home.invites + +import androidx.paging.PagedList +import com.airbnb.mvrx.MavericksViewModelFactory +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import im.vector.app.core.di.MavericksAssistedViewModelFactory +import im.vector.app.core.di.hiltMavericksViewModelFactory +import im.vector.app.core.platform.VectorViewModel +import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.room.RoomSortOrder +import org.matrix.android.sdk.api.session.room.RoomSummaryQueryParams +import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState +import org.matrix.android.sdk.api.session.room.model.Membership +import timber.log.Timber + +class InvitesViewModel @AssistedInject constructor( + @Assisted val initialState: InvitesViewState, + private val session: Session, +) : VectorViewModel(initialState) { + + private val pagedListConfig = PagedList.Config.Builder() + .setPageSize(10) + .setInitialLoadSizeHint(20) + .setEnablePlaceholders(true) + .setPrefetchDistance(10) + .build() + + @AssistedFactory + interface Factory : MavericksAssistedViewModelFactory { + override fun create(initialState: InvitesViewState): InvitesViewModel + } + + companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() + + init { + observeInvites() + } + + override fun handle(action: InvitesAction) { + when (action) { + is InvitesAction.AcceptInvitation -> handleAcceptInvitation(action) + is InvitesAction.RejectInvitation -> handleRejectInvitation(action) + } + } + + private fun handleRejectInvitation(action: InvitesAction.RejectInvitation) = withState { state -> + val roomId = action.roomSummary.roomId + val roomMembershipChange = state.roomMembershipChanges[roomId] + if (roomMembershipChange?.isInProgress().orFalse()) { + // Request already sent, should not happen + Timber.w("Try to left an already leaving or joining room. Should not happen") + return@withState + } + + val shouldCloseInviteView = state.pagedList?.value?.size == 1 + + viewModelScope.launch { + try { + session.roomService().leaveRoom(roomId) + // We do not update the rejectingRoomsIds here, because, the room is not rejected yet regarding the sync data. + // Instead, we wait for the room to be rejected + // Known bug: if the user is invited again (after rejecting the first invitation), the loading will be displayed instead of the buttons. + // If we update the state, the button will be displayed again, so it's not ideal... + if (shouldCloseInviteView) { + _viewEvents.post(InvitesViewEvents.Close) + } + } catch (failure: Throwable) { + // Notify the user + _viewEvents.post(InvitesViewEvents.Failure(failure)) + } + } + } + + private fun handleAcceptInvitation(action: InvitesAction.AcceptInvitation) = withState { state -> + val roomId = action.roomSummary.roomId + val roomMembershipChange = state.roomMembershipChanges[roomId] + if (roomMembershipChange?.isInProgress().orFalse()) { + // Request already sent, should not happen + Timber.w("Try to join an already joining room. Should not happen") + return@withState + } + // close invites view when navigate to a room from the last one invite + + val shouldCloseInviteView = state.pagedList?.value?.size == 1 + + _viewEvents.post(InvitesViewEvents.OpenRoom(action.roomSummary, shouldCloseInviteView)) + + // quick echo + setState { + copy( + roomMembershipChanges = roomMembershipChanges.mapValues { + if (it.key == roomId) { + ChangeMembershipState.Joining + } else { + it.value + } + } + ) + } + } + + private fun observeInvites() { + val builder = RoomSummaryQueryParams.Builder().also { + it.memberships = listOf(Membership.INVITE) + } + val pagedList = session.roomService().getPagedRoomSummariesLive( + queryParams = builder.build(), + pagedListConfig = pagedListConfig, + sortOrder = RoomSortOrder.ACTIVITY + ) + + setState { + copy(pagedList = pagedList) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesViewState.kt new file mode 100644 index 0000000000..708db29604 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/list/home/invites/InvitesViewState.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.home.room.list.home.invites + +import androidx.lifecycle.LiveData +import androidx.paging.PagedList +import com.airbnb.mvrx.MavericksState +import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState +import org.matrix.android.sdk.api.session.room.model.RoomSummary + +data class InvitesViewState( + val pagedList: LiveData>? = null, + val roomMembershipChanges: Map = emptyMap(), +) : MavericksState diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedServerSelectionFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedServerSelectionFragment.kt index abb8e4ed7d..f39946a1d0 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedServerSelectionFragment.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCombinedServerSelectionFragment.kt @@ -28,6 +28,7 @@ import im.vector.app.core.extensions.content import im.vector.app.core.extensions.editText import im.vector.app.core.extensions.realignPercentagesToParent import im.vector.app.core.extensions.setOnImeDoneListener +import im.vector.app.core.extensions.showKeyboard import im.vector.app.core.extensions.toReducedUrl import im.vector.app.core.utils.ensureProtocol import im.vector.app.core.utils.ensureTrailingSlash @@ -91,6 +92,9 @@ class FtueAuthCombinedServerSelectionFragment : val userUrlInput = state.selectedHomeserver.userFacingUrl?.toReducedUrlKeepingSchemaIfInsecure() ?: viewModel.getDefaultHomeserverUrl() views.chooseServerInput.editText().setText(userUrlInput) } + + views.chooseServerInput.editText().selectAll() + views.chooseServerInput.editText().showKeyboard(true) } override fun onError(throwable: Throwable) { diff --git a/vector/src/main/java/im/vector/app/features/spaces/NewSpaceSummaryController.kt b/vector/src/main/java/im/vector/app/features/spaces/NewSpaceSummaryController.kt index 524c7d82af..6d8f6b7b36 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/NewSpaceSummaryController.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/NewSpaceSummaryController.kt @@ -22,6 +22,7 @@ import im.vector.app.core.resources.StringProvider import im.vector.app.features.grouplist.newHomeSpaceSummaryItem import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.list.UnreadCounterBadgeView +import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo @@ -51,6 +52,7 @@ class NewSpaceSummaryController @Inject constructor( nonNullViewState.selectedSpace, nonNullViewState.rootSpacesOrdered, nonNullViewState.homeAggregateCount, + nonNullViewState.expandedStates, ) } @@ -59,20 +61,13 @@ class NewSpaceSummaryController @Inject constructor( selectedSpace: RoomSummary?, rootSpaces: List?, homeCount: RoomAggregateNotificationCount, + expandedStates: Map, ) { val host = this - if (selectedSpace != null) { - addSubSpaces(selectedSpace, spaceSummaries, homeCount) - } else { - addHomeItem(true, homeCount) - addRootSpaces(rootSpaces) - } - - newSpaceAddItem { - id("create") - listener { host.callback?.onAddSpaceSelected() } - } + addHomeItem(selectedSpace == null, homeCount) + addSpaces(spaceSummaries, selectedSpace, rootSpaces, expandedStates) + addCreateItem() } private fun addHomeItem(selected: Boolean, homeCount: RoomAggregateNotificationCount) { @@ -86,60 +81,95 @@ class NewSpaceSummaryController @Inject constructor( } } - private fun addSubSpaces( - selectedSpace: RoomSummary, + private fun addSpaces( spaceSummaries: List?, - homeCount: RoomAggregateNotificationCount, + selectedSpace: RoomSummary?, + rootSpaces: List?, + expandedStates: Map, ) { val host = this - val spaceChildren = selectedSpace.spaceChildren - var subSpacesAdded = false - spaceChildren?.sortedWith(subSpaceComparator)?.forEach { spaceChild -> - val subSpaceSummary = spaceSummaries?.firstOrNull { it.roomId == spaceChild.childRoomId } ?: return@forEach + rootSpaces?.filter { it.membership != Membership.INVITE } + ?.forEach { spaceSummary -> + val subSpaces = spaceSummary.spaceChildren?.filter { spaceChild -> spaceSummaries.containsSpaceId(spaceChild.childRoomId) } + val hasChildren = (subSpaces?.size ?: 0) > 0 + val isSelected = spaceSummary.roomId == selectedSpace?.roomId + val expanded = expandedStates[spaceSummary.roomId] == true - if (subSpaceSummary.membership != Membership.INVITE) { - subSpacesAdded = true - newSpaceSummaryItem { - avatarRenderer(host.avatarRenderer) - id(subSpaceSummary.roomId) - matrixItem(subSpaceSummary.toMatrixItem()) - selected(false) - listener { host.callback?.onSpaceSelected(subSpaceSummary) } - countState( - UnreadCounterBadgeView.State( - subSpaceSummary.notificationCount, - subSpaceSummary.highlightCount > 0 - ) - ) + newSpaceSummaryItem { + id(spaceSummary.roomId) + avatarRenderer(host.avatarRenderer) + countState(UnreadCounterBadgeView.State(spaceSummary.notificationCount, spaceSummary.highlightCount > 0)) + expanded(expanded) + hasChildren(hasChildren) + matrixItem(spaceSummary.toMatrixItem()) + onLongClickListener { host.callback?.onSpaceSettings(spaceSummary) } + onSpaceSelectedListener { host.callback?.onSpaceSelected(spaceSummary) } + onToggleExpandListener { host.callback?.onToggleExpand(spaceSummary) } + selected(isSelected) + } + + if (hasChildren && expanded) { + subSpaces?.forEach { child -> + addSubSpace(spaceSummary.roomId, spaceSummaries, expandedStates, selectedSpace, child, 1) + } + } } - } + } + + private fun List?.containsSpaceId(spaceId: String) = this?.any { it.roomId == spaceId }.orFalse() + + private fun addSubSpace( + idPrefix: String, + spaceSummaries: List?, + expandedStates: Map, + selectedSpace: RoomSummary?, + info: SpaceChildInfo, + depth: Int, + ) { + val host = this + val childSummary = spaceSummaries?.firstOrNull { it.roomId == info.childRoomId } ?: return + val id = "$idPrefix:${childSummary.roomId}" + val countState = UnreadCounterBadgeView.State(childSummary.notificationCount, childSummary.highlightCount > 0) + val expanded = expandedStates[childSummary.roomId] == true + val isSelected = childSummary.roomId == selectedSpace?.roomId + val subSpaces = childSummary.spaceChildren?.filter { childSpace -> spaceSummaries.containsSpaceId(childSpace.childRoomId) } + ?.sortedWith(subSpaceComparator) + + newSubSpaceSummaryItem { + id(id) + avatarRenderer(host.avatarRenderer) + countState(countState) + expanded(expanded) + hasChildren(!subSpaces.isNullOrEmpty()) + indent(depth) + matrixItem(childSummary.toMatrixItem()) + onLongClickListener { host.callback?.onSpaceSettings(childSummary) } + onSubSpaceSelectedListener { host.callback?.onSpaceSelected(childSummary) } + onToggleExpandListener { host.callback?.onToggleExpand(childSummary) } + selected(isSelected) } - if (!subSpacesAdded) { - addHomeItem(false, homeCount) + if (expanded) { + subSpaces?.forEach { + addSubSpace(id, spaceSummaries, expandedStates, selectedSpace, it, depth + 1) + } } } - private fun addRootSpaces(rootSpaces: List?) { + private fun addCreateItem() { val host = this - rootSpaces - ?.filter { it.membership != Membership.INVITE } - ?.forEach { roomSummary -> - newSpaceSummaryItem { - avatarRenderer(host.avatarRenderer) - id(roomSummary.roomId) - matrixItem(roomSummary.toMatrixItem()) - listener { host.callback?.onSpaceSelected(roomSummary) } - countState(UnreadCounterBadgeView.State(roomSummary.notificationCount, roomSummary.highlightCount > 0)) - } - } + newSpaceAddItem { + id("create") + listener { host.callback?.onAddSpaceSelected() } + } } interface Callback { fun onSpaceSelected(spaceSummary: RoomSummary?) fun onSpaceInviteSelected(spaceSummary: RoomSummary) fun onSpaceSettings(spaceSummary: RoomSummary) + fun onToggleExpand(spaceSummary: RoomSummary) fun onAddSpaceSelected() fun sendFeedBack() } diff --git a/vector/src/main/java/im/vector/app/features/spaces/NewSpaceSummaryItem.kt b/vector/src/main/java/im/vector/app/features/spaces/NewSpaceSummaryItem.kt index 778b9c933e..f6a4781860 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/NewSpaceSummaryItem.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/NewSpaceSummaryItem.kt @@ -18,6 +18,7 @@ package im.vector.app.features.spaces import android.widget.ImageView import android.widget.TextView +import androidx.core.view.isVisible import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass import im.vector.app.R @@ -34,16 +35,30 @@ import org.matrix.android.sdk.api.util.MatrixItem abstract class NewSpaceSummaryItem : VectorEpoxyModel(R.layout.item_new_space) { @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) lateinit var avatarRenderer: AvatarRenderer - @EpoxyAttribute lateinit var matrixItem: MatrixItem - @EpoxyAttribute var selected: Boolean = false - @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var listener: ClickListener? = null @EpoxyAttribute var countState: UnreadCounterBadgeView.State = UnreadCounterBadgeView.State(0, false) + @EpoxyAttribute var expanded: Boolean = false + @EpoxyAttribute var hasChildren: Boolean = false + @EpoxyAttribute lateinit var matrixItem: MatrixItem + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var onLongClickListener: ClickListener? = null + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var onSpaceSelectedListener: ClickListener? = null + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var onToggleExpandListener: ClickListener? = null + @EpoxyAttribute var selected: Boolean = false override fun bind(holder: Holder) { super.bind(holder) - holder.rootView.onClick(listener) + val context = holder.root.context + holder.root.onClick(onSpaceSelectedListener) + holder.root.setOnLongClickListener { + onLongClickListener?.invoke(holder.root) + true + } holder.name.text = matrixItem.displayName - holder.rootView.isChecked = selected + holder.root.isChecked = selected + + holder.chevron.setOnClickListener(onToggleExpandListener) + holder.chevron.isVisible = hasChildren + holder.chevron.setImageResource(if (expanded) R.drawable.ic_expand_more else R.drawable.ic_arrow_right) + holder.chevron.contentDescription = context.getString(if (expanded) R.string.a11y_collapse_space_children else R.string.a11y_expand_space_children) avatarRenderer.render(matrixItem, holder.avatar) holder.unreadCounter.render(countState) @@ -55,9 +70,10 @@ abstract class NewSpaceSummaryItem : VectorEpoxyModel(R.id.root) + val root by bind(R.id.root) val avatar by bind(R.id.avatar) val name by bind(R.id.name) val unreadCounter by bind(R.id.unread_counter) + val chevron by bind(R.id.chevron) } } diff --git a/vector/src/main/java/im/vector/app/features/spaces/NewSubSpaceSummaryItem.kt b/vector/src/main/java/im/vector/app/features/spaces/NewSubSpaceSummaryItem.kt new file mode 100644 index 0000000000..8dd2aea9b3 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/spaces/NewSubSpaceSummaryItem.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2021 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 im.vector.app.features.spaces + +import android.widget.ImageView +import android.widget.Space +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.app.R +import im.vector.app.core.epoxy.ClickListener +import im.vector.app.core.epoxy.VectorEpoxyHolder +import im.vector.app.core.epoxy.VectorEpoxyModel +import im.vector.app.core.epoxy.onClick +import im.vector.app.core.platform.CheckableConstraintLayout +import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.home.room.list.UnreadCounterBadgeView +import org.matrix.android.sdk.api.util.MatrixItem + +@EpoxyModelClass +abstract class NewSubSpaceSummaryItem : VectorEpoxyModel(R.layout.item_new_sub_space) { + + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) lateinit var avatarRenderer: AvatarRenderer + @EpoxyAttribute var countState: UnreadCounterBadgeView.State = UnreadCounterBadgeView.State(0, false) + @EpoxyAttribute var expanded: Boolean = false + @EpoxyAttribute var hasChildren: Boolean = false + @EpoxyAttribute var indent: Int = 0 + @EpoxyAttribute lateinit var matrixItem: MatrixItem + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var onLongClickListener: ClickListener? = null + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var onSubSpaceSelectedListener: ClickListener? = null + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var onToggleExpandListener: ClickListener? = null + @EpoxyAttribute var selected: Boolean = false + + override fun bind(holder: Holder) { + super.bind(holder) + holder.root.onClick(onSubSpaceSelectedListener) + holder.name.text = matrixItem.displayName + holder.root.isChecked = selected + holder.root.setOnLongClickListener { onLongClickListener?.invoke(holder.root).let { true } } + + holder.chevron.setImageDrawable( + ContextCompat.getDrawable( + holder.view.context, + if (expanded) R.drawable.ic_expand_more else R.drawable.ic_arrow_right + ) + ) + holder.chevron.onClick(onToggleExpandListener) + holder.chevron.isVisible = hasChildren + + holder.indent.isVisible = indent > 0 + holder.indent.updateLayoutParams { + width = indent * 30 + } + + avatarRenderer.render(matrixItem, holder.avatar) + holder.notificationBadge.render(countState) + } + + override fun unbind(holder: Holder) { + avatarRenderer.clear(holder.avatar) + super.unbind(holder) + } + + class Holder : VectorEpoxyHolder() { + val avatar by bind(R.id.avatar) + val name by bind(R.id.name) + val root by bind(R.id.root) + val chevron by bind(R.id.chevron) + val indent by bind(R.id.indent) + val notificationBadge by bind(R.id.notification_badge) + } +} diff --git a/vector/src/main/java/im/vector/app/features/spaces/SpaceListFragment.kt b/vector/src/main/java/im/vector/app/features/spaces/SpaceListFragment.kt index ca22ac30a1..ca9279cb37 100644 --- a/vector/src/main/java/im/vector/app/features/spaces/SpaceListFragment.kt +++ b/vector/src/main/java/im/vector/app/features/spaces/SpaceListFragment.kt @@ -77,7 +77,6 @@ class SpaceListFragment : private fun setupSpaceController() { if (vectorFeatures.isNewAppLayoutEnabled()) { - enableDragAndDropForNewSpaceController() newSpaceController.callback = this views.groupListView.configureWith(newSpaceController) } else { @@ -87,49 +86,6 @@ class SpaceListFragment : } } - private fun enableDragAndDropForNewSpaceController() { - EpoxyTouchHelper.initDragging(newSpaceController) - .withRecyclerView(views.groupListView) - .forVerticalList() - .withTarget(NewSpaceSummaryItem::class.java) - .andCallbacks(object : EpoxyTouchHelper.DragCallbacks() { - var toPositionM: Int? = null - var fromPositionM: Int? = null - var initialElevation: Float? = null - - override fun onDragStarted(model: NewSpaceSummaryItem?, itemView: View?, adapterPosition: Int) { - toPositionM = null - fromPositionM = null - model?.matrixItem?.id?.let { - viewModel.handle(SpaceListAction.OnStartDragging(it, false)) - } - itemView?.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) - initialElevation = itemView?.elevation - itemView?.elevation = 6f - } - - override fun onDragReleased(model: NewSpaceSummaryItem?, itemView: View?) { - if (toPositionM == null || fromPositionM == null) return - val movedSpaceId = model?.matrixItem?.id ?: return - viewModel.handle(SpaceListAction.MoveSpace(movedSpaceId, toPositionM!! - fromPositionM!!)) - } - - override fun clearView(model: NewSpaceSummaryItem?, itemView: View?) { - itemView?.elevation = initialElevation ?: 0f - } - - override fun onModelMoved(fromPosition: Int, toPosition: Int, modelBeingMoved: NewSpaceSummaryItem?, itemView: View?) { - if (fromPositionM == null) { - fromPositionM = fromPosition - } - if (toPositionM != toPosition) { - toPositionM = toPosition - itemView?.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) - } - } - }) - } - private fun enableDragAndDropForSpaceController() { EpoxyTouchHelper.initDragging(spaceController) .withRecyclerView(views.groupListView) diff --git a/vector/src/main/res/layout/fragment_invites.xml b/vector/src/main/res/layout/fragment_invites.xml new file mode 100644 index 0000000000..74226357c9 --- /dev/null +++ b/vector/src/main/res/layout/fragment_invites.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/item_invites_count.xml b/vector/src/main/res/layout/item_invites_count.xml new file mode 100644 index 0000000000..6408749941 --- /dev/null +++ b/vector/src/main/res/layout/item_invites_count.xml @@ -0,0 +1,48 @@ + + + + + + + + + diff --git a/vector/src/main/res/layout/item_new_space.xml b/vector/src/main/res/layout/item_new_space.xml index 367d69ce69..fc023ebd6e 100644 --- a/vector/src/main/res/layout/item_new_space.xml +++ b/vector/src/main/res/layout/item_new_space.xml @@ -34,9 +34,9 @@ android:maxLines="1" android:textColor="?vctr_content_primary" android:textStyle="bold" - app:layout_constraintStart_toEndOf="@id/avatar" - app:layout_constraintEnd_toStartOf="@id/unread_counter" app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toStartOf="@id/unread_counter" + app:layout_constraintStart_toEndOf="@id/avatar" app:layout_constraintTop_toTopOf="parent" tools:text="Element Corp" /> @@ -53,25 +53,28 @@ android:paddingEnd="4dp" android:textColor="?colorOnError" android:visibility="gone" + app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toStartOf="@id/chevron" app:layout_constraintTop_toTopOf="parent" - app:layout_constraintBottom_toBottomOf="parent" tools:background="@drawable/bg_unread_highlight" tools:text="147" tools:visibility="visible" /> + tools:ignore="MissingPrefix" + tools:src="@drawable/ic_arrow_right" + tools:visibility="visible" /> diff --git a/vector/src/main/res/layout/item_new_sub_space.xml b/vector/src/main/res/layout/item_new_sub_space.xml new file mode 100644 index 0000000000..014568e26d --- /dev/null +++ b/vector/src/main/res/layout/item_new_sub_space.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index a43eac3664..ee718aec25 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -140,6 +140,8 @@ Start Chat Create Room Explore Rooms + Expand space children + Collapse space children @@ -440,6 +442,9 @@ "System Alerts" Suggested Rooms + + Invites + Conversations Matrix contacts only