Merge remote-tracking branch 'origin/develop' into feature/eric/new-layout-navigation

# Conflicts:
#	vector/src/main/java/im/vector/app/features/spaces/NewSpaceSummaryController.kt
This commit is contained in:
ericdecanini 2022-08-26 14:06:11 +02:00
commit 95b37e2838
58 changed files with 2314 additions and 244 deletions

1
changelog.d/5525.wip Normal file
View file

@ -0,0 +1 @@
Create DM room only on first message - Create the DM and navigate to the new room after sending an event

1
changelog.d/6889.wip Normal file
View file

@ -0,0 +1 @@
[App Layout] new room invites screen

1
changelog.d/6907.wip Normal file
View file

@ -0,0 +1 @@
[New Layout] Changes space sheet to accordion-style with expandable subspaces

1
changelog.d/6926.misc Normal file
View file

@ -0,0 +1 @@
Focus input field when editing homeserver address to speed up login and registration.

View file

@ -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"
],

View file

@ -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"

View file

@ -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"

View file

@ -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)
}

View file

@ -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<String>()
var invitedUserIds = mutableListOf<String>()
/**
* A list of objects representing third party IDs to invite into the room.
*/
val invite3pids = mutableListOf<ThreePid>()
var invite3pids = mutableListOf<ThreePid>()
/**
* 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<String, Any>()
var creationContent = mutableMapOf<String, Any>()
/**
* 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<CreateRoomStateEvent>()
var initialStates = mutableListOf<CreateRoomStateEvent>()
/**
* 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)
}

View file

@ -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.

View file

@ -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())
}

View file

@ -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()
}
}

View file

@ -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")!!)
}
}

View file

@ -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
}

View file

@ -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,

View file

@ -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<LocalRoomSummaryEntity> {
val query = realm.where<LocalRoomSummaryEntity>()
if (roomId != null) {
query.equalTo(LocalRoomSummaryEntityFields.ROOM_ID, roomId)
}
return query
}

View file

@ -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 {

View file

@ -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

View file

@ -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<Params, List<Event>> {
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<Event> {
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<Event>.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<Event>.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<Event>.createRoomMemberEvents(userIds: List<String>) {
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<Event>.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<Event>.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<Event>.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<Event>.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<Event>.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<Event>.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()
)
}
}

View file

@ -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<CreateRoomParams, String>
internal class DefaultCreateLocalRoomTask @Inject constructor(
@UserId private val userId: String,
@SessionDatabase private val monarchy: Monarchy,
private val roomMemberEventHandler: RoomMemberEventHandler,
private val roomSummaryUpdater: RoomSummaryUpdater,
@SessionDatabase private val realmConfiguration: RealmConfiguration,
private val createRoomBodyBuilder: CreateRoomBodyBuilder,
private val userService: UserService,
private val 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<RoomSummaryEntity>(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<LocalRoomSummaryEntity>(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<String, RoomMemberContent?>()
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<Event> {
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
}
}

View file

@ -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.

View file

@ -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<CreateRoomFromLocalRoomTask.Params, String> {
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<RoomTombstoneContent>()
?.replacementRoomId
if (replacementRoomId != null) {
return replacementRoomId
}
var createRoomParams: CreateRoomParams? = null
var isEncrypted = false
monarchy.doWithRealm { realm ->
realm.where<LocalRoomSummaryEntity>()
.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
}
}
}
}
}

View file

@ -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 {

View file

@ -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,
)

View file

@ -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")

View file

@ -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
}

View file

@ -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<SendStateTask.Params, String> {
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))
}
}

View file

@ -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<UserService>()
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>()
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<RoomNameContent>()?.name shouldBeEqualTo aRoomName
roomTopicEvent?.content.toModel<RoomTopicContent>()?.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<RoomMember> = 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<RoomMemberContent>()
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<PowerLevelsContent>() 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<RoomCanonicalAliasContent>()?.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<RoomJoinRulesContent>()
?.joinRules shouldBeEqualTo case.expectedResult.joinRules
result.find { it.type == EventType.STATE_ROOM_HISTORY_VISIBILITY }
?.content.toModel<RoomHistoryVisibilityContent>()
?.historyVisibility shouldBeEqualTo case.expectedResult.historyVisibility
result.find { it.type == EventType.STATE_ROOM_GUEST_ACCESS }
?.content.toModel<RoomGuestAccessContent>()
?.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<RoomThirdPartyInviteContent>() }
val localThirdPartyInviteEvents = result.filter { it.type == EventType.LOCAL_STATE_ROOM_THIRD_PARTY_INVITE }
val localThirdPartyInviteContents = localThirdPartyInviteEvents.map { it.content.toModel<LocalRoomThirdPartyInviteContent>() }
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<String>() == 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>()
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<RoomGuestAccessContent>()?.guestAccess shouldBeEqualTo GuestAccess.Forbidden
// History visibility
result.find { it.type == EventType.STATE_ROOM_HISTORY_VISIBILITY }
?.content.toModel<RoomHistoryVisibilityContent>()?.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<RoomHistoryVisibilityContent>()?.historyVisibility shouldBeEqualTo expectedHistoryVisibility
result.lastOrNull { it.type == EventType.STATE_ROOM_MEMBER }
?.content.toModel<RoomMemberContent>()?.isDirect shouldBeEqualTo expectedIsDirect
result.lastOrNull { it.type == EventType.LOCAL_STATE_ROOM_THIRD_PARTY_INVITE }
?.content.toModel<LocalRoomThirdPartyInviteContent>()?.isDirect shouldBeEqualTo expectedIsDirect
}
}

View file

@ -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<CreateRoomTask>()
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<Any>(realmConfiguration = any(), timeoutMillis = any(), builder = any()) }
mockkStatic("org.matrix.android.sdk.internal.database.query.EventEntityQueriesKt")
coEvery { any<EventEntity>().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<CreateRoomParams>()
val aLocalRoomSummaryEntity = mockk<LocalRoomSummaryEntity> {
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<LocalRoomSummaryEntity>()
.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<RoomTombstoneContent>()
?.replacementRoomId shouldBeEqualTo expectedRoomId
}
}

View file

@ -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)
)
}
}

View file

@ -33,7 +33,7 @@ import org.matrix.android.sdk.internal.util.awaitTransaction
internal class FakeMonarchy {
val instance = mockk<Monarchy>()
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<suspend (Realm) -> Any>().invoke(fakeRealm.instance)
}
coEvery {
instance.doWithRealm(any())
} coAnswers {
firstArg<Monarchy.RealmBlock>().doWithRealm(fakeRealm.instance)
}
every { instance.realmConfiguration } returns mockk()
}
inline fun <reified T : RealmModel> givenWhere(): RealmQuery<T> {

View file

@ -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
)
}
}

View file

@ -349,6 +349,7 @@
<activity android:name=".features.location.live.map.LiveLocationMapViewActivity" />
<activity android:name=".features.settings.font.FontScaleSettingActivity"/>
<activity android:name=".features.call.dialpad.PstnDialActivity" />
<activity android:name=".features.home.room.list.home.invites.InvitesActivity"/>
<!-- Services -->

View file

@ -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<*, *>
}

View file

@ -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<RoomTombstoneContent>()
?.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

View file

@ -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
}

View file

@ -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()
}

View file

@ -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<HomeRoomSection>()
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)

View file

@ -32,4 +32,8 @@ sealed class HomeRoomSection {
data class RecentRoomsData(
val list: LiveData<List<RoomSummary>>
) : HomeRoomSection()
data class InvitesCountData(
val count: LiveData<Int>
) : HomeRoomSection()
}

View file

@ -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<InviteCounterItem.Holder>(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<UnreadCounterBadgeView>(R.id.invites_count_badge)
}
}

View file

@ -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()
}

View file

@ -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<ActivitySimpleBinding>() {
override fun getBinding() = ActivitySimpleBinding.inflate(layoutInflater)
override fun initUiAndData() {
if (isFirstCreation()) {
addFragment(views.simpleFragmentContainer, InvitesFragment::class.java)
}
}
}

View file

@ -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<RoomSummary>(
// Important it must match the PageList builder notify Looper
modelBuildingHandler = createUIHandler()
) {
var roomChangeMembershipStates: Map<String, ChangeMembershipState>? = 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)
}
}

View file

@ -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()
}
}

View file

@ -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<FragmentInvitesBinding>(), 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
}

View file

@ -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()
}

View file

@ -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<InvitesViewState, InvitesAction, InvitesViewEvents>(initialState) {
private val pagedListConfig = PagedList.Config.Builder()
.setPageSize(10)
.setInitialLoadSizeHint(20)
.setEnablePlaceholders(true)
.setPrefetchDistance(10)
.build()
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<InvitesViewModel, InvitesViewState> {
override fun create(initialState: InvitesViewState): InvitesViewModel
}
companion object : MavericksViewModelFactory<InvitesViewModel, InvitesViewState> 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)
}
}
}

View file

@ -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<PagedList<RoomSummary>>? = null,
val roomMembershipChanges: Map<String, ChangeMembershipState> = emptyMap(),
) : MavericksState

View file

@ -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) {

View file

@ -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<RoomSummary>?,
homeCount: RoomAggregateNotificationCount,
expandedStates: Map<String, Boolean>,
) {
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<RoomSummary>?,
homeCount: RoomAggregateNotificationCount,
selectedSpace: RoomSummary?,
rootSpaces: List<RoomSummary>?,
expandedStates: Map<String, Boolean>,
) {
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<RoomSummary>?.containsSpaceId(spaceId: String) = this?.any { it.roomId == spaceId }.orFalse()
private fun addSubSpace(
idPrefix: String,
spaceSummaries: List<RoomSummary>?,
expandedStates: Map<String, Boolean>,
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<RoomSummary>?) {
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()
}

View file

@ -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<NewSpaceSummaryItem.Holder>(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<NewSpaceSummaryItem.Holder
}
class Holder : VectorEpoxyHolder() {
val rootView by bind<CheckableConstraintLayout>(R.id.root)
val root by bind<CheckableConstraintLayout>(R.id.root)
val avatar by bind<ImageView>(R.id.avatar)
val name by bind<TextView>(R.id.name)
val unreadCounter by bind<UnreadCounterBadgeView>(R.id.unread_counter)
val chevron by bind<ImageView>(R.id.chevron)
}
}

View file

@ -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<NewSubSpaceSummaryItem.Holder>(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<ImageView>(R.id.avatar)
val name by bind<TextView>(R.id.name)
val root by bind<CheckableConstraintLayout>(R.id.root)
val chevron by bind<ImageView>(R.id.chevron)
val indent by bind<Space>(R.id.indent)
val notificationBadge by bind<UnreadCounterBadgeView>(R.id.notification_badge)
}
}

View file

@ -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<NewSpaceSummaryItem>() {
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)

View file

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBarLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/invites_toolbar"
android:layout_width="match_parent"
android:layout_height="?actionBarSize"
app:title="@string/invites_title" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/invites_recycler"
android:layout_width="0dp"
android:layout_height="0dp"
android:fastScrollEnabled="true"
android:overScrollMode="always"
android:scrollbars="vertical"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/appBarLayout" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?vctr_toolbar_background"
android:clickable="true"
android:focusable="true"
tools:viewBindingIgnore="true">
<im.vector.app.features.home.room.list.UnreadCounterBadgeView
android:id="@+id/invites_count_badge"
style="@style/Widget.Vector.TextView.Micro"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:gravity="center"
android:minWidth="16dp"
android:minHeight="16dp"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:textColor="?colorOnError"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:background="@drawable/bg_unread_highlight"
tools:text="4"
tools:visibility="visible" />
<TextView
android:id="@+id/invites_count_title"
style="@style/Widget.Vector.TextView.Body"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical|end"
android:layout_marginEnd="8dp"
android:text="@string/invites_title"
android:textAllCaps="true"
android:textColor="?colorSecondary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/invites_count_badge"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -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" />
<ImageView
android:id="@+id/chevron"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_width="32dp"
android:layout_height="48dp"
android:layout_marginEnd="21dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_arrow_right"
android:visibility="visible"
android:background="?selectableItemBackground"
android:contentDescription="@string/a11y_expand_space_children"
android:scaleType="centerInside"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:tint="?vctr_content_secondary"
tools:ignore="MissingPrefix" />
tools:ignore="MissingPrefix"
tools:src="@drawable/ic_arrow_right"
tools:visibility="visible" />
</im.vector.app.core.platform.CheckableConstraintLayout>

View file

@ -0,0 +1,93 @@
<?xml version="1.0" encoding="utf-8"?>
<im.vector.app.core.platform.CheckableConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="40dp"
android:background="@drawable/bg_space_item"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
tools:viewBindingIgnore="true">
<Space
android:id="@+id/indent"
android:layout_width="20dp"
android:layout_height="match_parent"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
<ImageView
android:id="@+id/avatar"
android:layout_width="26dp"
android:layout_height="26dp"
android:layout_gravity="center"
android:layout_marginStart="@dimen/layout_horizontal_margin"
android:duplicateParentState="true"
android:importantForAccessibility="no"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/indent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@sample/space_avatars" />
<im.vector.app.features.home.room.list.UnreadCounterBadgeView
android:id="@+id/notification_badge"
style="@style/Widget.Vector.TextView.Micro"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginEnd="4dp"
android:gravity="center"
android:minWidth="16dp"
android:minHeight="16dp"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:textColor="?colorOnError"
android:visibility="gone"
app:layout_constraintCircle="@id/avatar"
app:layout_constraintCircleAngle="45"
app:layout_constraintCircleRadius="14dp"
tools:background="@drawable/bg_unread_highlight"
tools:text="147"
tools:visibility="visible" />
<TextView
android:id="@+id/name"
style="@style/Widget.Vector.TextView.Subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/layout_horizontal_margin"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?vctr_content_primary"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/chevron"
app:layout_constraintStart_toEndOf="@id/avatar"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:text="@tools:sample/lorem/random" />
<ImageView
android:id="@+id/chevron"
android:layout_width="24dp"
android:layout_height="32dp"
android:layout_marginEnd="24dp"
android:background="?selectableItemBackground"
android:contentDescription="@string/a11y_expand_space_children"
android:scaleType="centerInside"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:tint="?vctr_content_secondary"
tools:ignore="MissingPrefix"
tools:src="@drawable/ic_arrow_right"
tools:visibility="visible" />
</im.vector.app.core.platform.CheckableConstraintLayout>

View file

@ -140,6 +140,8 @@
<string name="start_chat">Start Chat</string>
<string name="create_room">Create Room</string>
<string name="explore_rooms">Explore Rooms</string>
<string name="a11y_expand_space_children">Expand space children</string>
<string name="a11y_collapse_space_children">Collapse space children</string>
<!-- Last seen time -->
@ -440,6 +442,9 @@
<string name="system_alerts_header">"System Alerts"</string>
<string name="suggested_header">Suggested Rooms</string>
<!-- Invites fragment -->
<string name="invites_title">Invites</string>
<!-- People fragment -->
<string name="direct_chats_header">Conversations</string>
<string name="matrix_only_filter">Matrix contacts only</string>