diff --git a/CHANGES.md b/CHANGES.md index 94af9346eb..8f9d4f956b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -12,6 +12,8 @@ Improvements 🙌: - Setup server recovery banner (#1648) - Set up SSSS from security settings (#1567) - New lab setting to add 'unread notifications' tab to main screen + - Render third party invite event (#548) + - Display three pid invites in the room members list (#548) Bugfix 🐛: - Integration Manager: Wrong URL to review terms if URL in config contains path (#1606) @@ -27,7 +29,7 @@ Translations 🗣: - SDK API changes ⚠️: - - + - CreateRoomParams has been updated Build 🧱: - Upgrade some dependencies diff --git a/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxRoom.kt b/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxRoom.kt index b91949778d..e945a52650 100644 --- a/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxRoom.kt +++ b/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxRoom.kt @@ -19,6 +19,7 @@ package im.vector.matrix.rx import android.net.Uri import im.vector.matrix.android.api.query.QueryStringValue import im.vector.matrix.android.api.session.events.model.Event +import im.vector.matrix.android.api.session.identity.ThreePid import im.vector.matrix.android.api.session.room.Room import im.vector.matrix.android.api.session.room.members.RoomMemberQueryParams import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary @@ -71,6 +72,13 @@ class RxRoom(private val room: Room) { } } + fun liveStateEvents(eventTypes: Set): Observable> { + return room.getStateEventsLive(eventTypes).asObservable() + .startWithCallable { + room.getStateEvents(eventTypes) + } + } + fun liveReadMarker(): Observable> { return room.getReadMarkerLive().asObservable() } @@ -104,6 +112,10 @@ class RxRoom(private val room: Room) { room.invite(userId, reason, it) } + fun invite3pid(threePid: ThreePid): Completable = completableBuilder { + room.invite3pid(threePid, it) + } + fun updateTopic(topic: String): Completable = completableBuilder { room.updateTopic(topic, it) } diff --git a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/common/CryptoTestHelper.kt b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/common/CryptoTestHelper.kt index 5425f97fc4..08c24227be 100644 --- a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/common/CryptoTestHelper.kt +++ b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/common/CryptoTestHelper.kt @@ -65,7 +65,7 @@ class CryptoTestHelper(private val mTestHelper: CommonTestHelper) { val aliceSession = mTestHelper.createAccount(TestConstants.USER_ALICE, defaultSessionParams) val roomId = mTestHelper.doSync { - aliceSession.createRoom(CreateRoomParams(name = "MyRoom"), it) + aliceSession.createRoom(CreateRoomParams().apply { name = "MyRoom" }, it) } if (encryptedRoom) { @@ -175,7 +175,7 @@ class CryptoTestHelper(private val mTestHelper: CommonTestHelper) { } mTestHelper.doSync { - samSession.joinRoom(room.roomId, null, it) + samSession.joinRoom(room.roomId, null, emptyList(), it) } return samSession @@ -286,9 +286,11 @@ class CryptoTestHelper(private val mTestHelper: CommonTestHelper) { fun createDM(alice: Session, bob: Session): String { val roomId = mTestHelper.doSync { alice.createRoom( - CreateRoomParams(invitedUserIds = listOf(bob.myUserId)) - .setDirectMessage() - .enableEncryptionIfInvitedUsersSupportIt(), + CreateRoomParams().apply { + invitedUserIds.add(bob.myUserId) + setDirectMessage() + enableEncryptionIfInvitedUsersSupportIt = true + }, it ) } diff --git a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/internal/crypto/gossiping/KeyShareTests.kt b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/internal/crypto/gossiping/KeyShareTests.kt index e78ef04050..a5c0913909 100644 --- a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/internal/crypto/gossiping/KeyShareTests.kt +++ b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/internal/crypto/gossiping/KeyShareTests.kt @@ -66,7 +66,10 @@ class KeyShareTests : InstrumentedTest { // Create an encrypted room and add a message val roomId = mTestHelper.doSync { aliceSession.createRoom( - CreateRoomParams(RoomDirectoryVisibility.PRIVATE).enableEncryptionWithAlgorithm(true), + CreateRoomParams().apply { + visibility = RoomDirectoryVisibility.PRIVATE + enableEncryption() + }, it ) } @@ -285,7 +288,7 @@ class KeyShareTests : InstrumentedTest { mTestHelper.waitWithLatch(60_000) { latch -> val keysBackupService = aliceSession2.cryptoService().keysBackupService() mTestHelper.retryPeriodicallyWithLatch(latch) { - Log.d("#TEST", "Recovery :${ keysBackupService.getKeyBackupRecoveryKeyInfo()?.recoveryKey}") + Log.d("#TEST", "Recovery :${keysBackupService.getKeyBackupRecoveryKeyInfo()?.recoveryKey}") keysBackupService.getKeyBackupRecoveryKeyInfo()?.recoveryKey == creationInfo.recoveryKey } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/extensions/Strings.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/extensions/Strings.kt new file mode 100644 index 0000000000..202c15b5b0 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/extensions/Strings.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2020 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.matrix.android.api.extensions + +fun CharSequence.ensurePrefix(prefix: CharSequence): CharSequence { + return when { + startsWith(prefix) -> this + else -> "$prefix$this" + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/RoomService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/RoomService.kt index 3319cecfef..4e7b973bba 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/RoomService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/RoomService.kt @@ -113,5 +113,5 @@ interface RoomService { */ fun getChangeMembershipsLive(): LiveData> - fun getExistingDirectRoomWithUser(otherUserId: String) : Room? + fun getExistingDirectRoomWithUser(otherUserId: String): Room? } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/members/MembershipService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/members/MembershipService.kt index f011d317cd..bb74b5afa5 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/members/MembershipService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/members/MembershipService.kt @@ -18,6 +18,7 @@ package im.vector.matrix.android.api.session.room.members import androidx.lifecycle.LiveData import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.session.identity.ThreePid import im.vector.matrix.android.api.session.room.model.RoomMemberSummary import im.vector.matrix.android.api.util.Cancelable @@ -63,6 +64,12 @@ interface MembershipService { reason: String? = null, callback: MatrixCallback): Cancelable + /** + * Invite a user with email or phone number in the room + */ + fun invite3pid(threePid: ThreePid, + callback: MatrixCallback): Cancelable + /** * Ban a user from the room */ diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomThirdPartyInviteContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomThirdPartyInviteContent.kt new file mode 100644 index 0000000000..fa871d186e --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomThirdPartyInviteContent.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2019 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.matrix.android.api.session.room.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +/** + * Class representing the EventType.STATE_ROOM_THIRD_PARTY_INVITE state event content + * Ref: https://matrix.org/docs/spec/client_server/r0.6.1#m-room-third-party-invite + */ +@JsonClass(generateAdapter = true) +data class RoomThirdPartyInviteContent( + /** + * Required. A user-readable string which represents the user who has been invited. + * This should not contain the user's third party ID, as otherwise when the invite + * is accepted it would leak the association between the matrix ID and the third party ID. + */ + @Json(name = "display_name") val displayName: String, + + /** + * Required. A URL which can be fetched, with querystring public_key=public_key, to validate + * whether the key has been revoked. The URL must return a JSON object containing a boolean property named 'valid'. + */ + @Json(name = "key_validity_url") val keyValidityUrl: String, + + /** + * Required. A base64-encoded ed25519 key with which token must be signed (though a signature from any entry in + * public_keys is also sufficient). This exists for backwards compatibility. + */ + @Json(name = "public_key") val publicKey: String, + + /** + * Keys with which the token may be signed. + */ + @Json(name = "public_keys") val publicKeys: List = emptyList() +) + +@JsonClass(generateAdapter = true) +data class PublicKeys( + /** + * An optional URL which can be fetched, with querystring public_key=public_key, to validate whether the key + * has been revoked. The URL must return a JSON object containing a boolean property named 'valid'. If this URL + * is absent, the key must be considered valid indefinitely. + */ + @Json(name = "key_validity_url") val keyValidityUrl: String? = null, + + /** + * Required. A base-64 encoded ed25519 key with which token may be signed. + */ + @Json(name = "public_key") val publicKey: String +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/create/CreateRoomParams.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/create/CreateRoomParams.kt index 1abbe9ef3a..f89558801d 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/create/CreateRoomParams.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/create/CreateRoomParams.kt @@ -1,5 +1,5 @@ /* - * Copyright 2019 New Vector Ltd + * Copyright (c) 2020 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. @@ -16,253 +16,102 @@ package im.vector.matrix.android.api.session.room.model.create -import android.util.Patterns -import androidx.annotation.CheckResult -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass -import im.vector.matrix.android.api.MatrixPatterns.isUserId -import im.vector.matrix.android.api.auth.data.HomeServerConnectionConfig -import im.vector.matrix.android.api.session.events.model.Event -import im.vector.matrix.android.api.session.events.model.EventType -import im.vector.matrix.android.api.session.events.model.toContent +import im.vector.matrix.android.api.session.identity.ThreePid import im.vector.matrix.android.api.session.room.model.PowerLevelsContent import im.vector.matrix.android.api.session.room.model.RoomDirectoryVisibility import im.vector.matrix.android.api.session.room.model.RoomHistoryVisibility -import im.vector.matrix.android.internal.auth.data.ThreePidMedium import im.vector.matrix.android.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM -import timber.log.Timber -/** - * Parameter to create a room, with facilities functions to configure it - */ -@JsonClass(generateAdapter = true) -data class CreateRoomParams( - /** - * A public visibility indicates that the room will be shown in the published room list. - * A private visibility will hide the room from the published room list. - * Rooms default to private visibility if this key is not included. - * NB: This should not be confused with join_rules which also uses the word public. One of: ["public", "private"] - */ - @Json(name = "visibility") - val visibility: RoomDirectoryVisibility? = null, - - /** - * The desired room alias local part. If this is included, a room alias will be created and mapped to the newly created room. - * The alias will belong on the same homeserver which created the room. - * For example, if this was set to "foo" and sent to the homeserver "example.com" the complete room alias would be #foo:example.com. - */ - @Json(name = "room_alias_name") - val roomAliasName: String? = null, - - /** - * If this is included, an m.room.name event will be sent into the room to indicate the name of the room. - * See Room Events for more information on m.room.name. - */ - @Json(name = "name") - val name: String? = null, - - /** - * If this is included, an m.room.topic event will be sent into the room to indicate the topic for the room. - * See Room Events for more information on m.room.topic. - */ - @Json(name = "topic") - val topic: String? = null, - - /** - * 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. - */ - @Json(name = "invite") - val invitedUserIds: List? = null, - - /** - * A list of objects representing third party IDs to invite into the room. - */ - @Json(name = "invite_3pid") - val invite3pids: List? = null, - - /** - * Extra keys to be added to the content of the m.room.create. - * The server will clobber the following keys: creator. - * Future versions of the specification may allow the server to clobber other keys. - */ - @Json(name = "creation_content") - val creationContent: Any? = null, - - /** - * 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 presets, but gets overridden by name and topic keys. - */ - @Json(name = "initial_state") - val initialStates: List? = null, - - /** - * Convenience parameter for setting various default state events based on a preset. Must be either: - * private_chat => join_rules is set to invite. history_visibility is set to shared. - * trusted_private_chat => join_rules is set to invite. history_visibility is set to shared. All invitees are given the same power level as the - * room creator. - * public_chat: => join_rules is set to public. history_visibility is set to shared. - */ - @Json(name = "preset") - val preset: CreateRoomPreset? = null, - - /** - * This flag makes the server set the is_direct flag on the m.room.member events sent to the users in invite and invite_3pid. - * See Direct Messaging for more information. - */ - @Json(name = "is_direct") - val isDirect: Boolean? = null, - - /** - * The power level content to override in the default power level event - */ - @Json(name = "power_level_content_override") - val powerLevelContentOverride: PowerLevelsContent? = null -) { - @Transient - internal var enableEncryptionIfInvitedUsersSupportIt: Boolean = false - private set +// TODO Give a way to include other initial states +class CreateRoomParams { + /** + * A public visibility indicates that the room will be shown in the published room list. + * A private visibility will hide the room from the published room list. + * Rooms default to private visibility if this key is not included. + * NB: This should not be confused with join_rules which also uses the word public. One of: ["public", "private"] + */ + var visibility: RoomDirectoryVisibility? = null /** - * After calling this method, when the room will be created, if cross-signing is enabled and we can get keys for every invited users, + * The desired room alias local part. If this is included, a room alias will be created and mapped to the newly created room. + * The alias will belong on the same homeserver which created the room. + * For example, if this was set to "foo" and sent to the homeserver "example.com" the complete room alias would be #foo:example.com. + */ + var roomAliasName: String? = null + + /** + * If this is not null, an m.room.name event will be sent into the room to indicate the name of the room. + * See Room Events for more information on m.room.name. + */ + var name: String? = null + + /** + * If this is not null, an m.room.topic event will be sent into the room to indicate the topic for the room. + * See Room Events for more information on m.room.topic. + */ + var topic: String? = null + + /** + * 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() + + /** + * A list of objects representing third party IDs to invite into the room. + */ + val invite3pids = mutableListOf() + + /** + * If set to true, when the room will be created, if cross-signing is enabled and we can get keys for every invited users, * the encryption will be enabled on the created room - * @param value true to activate this behavior. - * @return this, to allow chaining methods */ - fun enableEncryptionIfInvitedUsersSupportIt(value: Boolean = true): CreateRoomParams { - enableEncryptionIfInvitedUsersSupportIt = value - return this - } + var enableEncryptionIfInvitedUsersSupportIt: Boolean = false /** - * Add the crypto algorithm to the room creation parameters. - * - * @param enable true to enable encryption. - * @param algorithm the algorithm, default to [MXCRYPTO_ALGORITHM_MEGOLM], which is actually the only supported algorithm for the moment - * @return a modified copy of the CreateRoomParams object, or this if there is no modification + * Convenience parameter for setting various default state events based on a preset. Must be either: + * private_chat => join_rules is set to invite. history_visibility is set to shared. + * trusted_private_chat => join_rules is set to invite. history_visibility is set to shared. All invitees are given the same power level as the + * room creator. + * public_chat: => join_rules is set to public. history_visibility is set to shared. */ - @CheckResult - fun enableEncryptionWithAlgorithm(enable: Boolean = true, - algorithm: String = MXCRYPTO_ALGORITHM_MEGOLM): CreateRoomParams { - // Remove the existing value if any. - val newInitialStates = initialStates - ?.filter { it.type != EventType.STATE_ROOM_ENCRYPTION } - - return if (algorithm == MXCRYPTO_ALGORITHM_MEGOLM) { - if (enable) { - val contentMap = mapOf("algorithm" to algorithm) - - val algoEvent = Event( - type = EventType.STATE_ROOM_ENCRYPTION, - stateKey = "", - content = contentMap.toContent() - ) - - copy( - initialStates = newInitialStates.orEmpty() + algoEvent - ) - } else { - return copy( - initialStates = newInitialStates - ) - } - } else { - Timber.e("Unsupported algorithm: $algorithm") - this - } - } + var preset: CreateRoomPreset? = null /** - * Force the history visibility in the room creation parameters. - * - * @param historyVisibility the expected history visibility, set null to remove any existing value. - * @return a modified copy of the CreateRoomParams object + * This flag makes the server set the is_direct flag on the m.room.member events sent to the users in invite and invite_3pid. + * See Direct Messaging for more information. */ - @CheckResult - fun setHistoryVisibility(historyVisibility: RoomHistoryVisibility?): CreateRoomParams { - // Remove the existing value if any. - val newInitialStates = initialStates - ?.filter { it.type != EventType.STATE_ROOM_HISTORY_VISIBILITY } + var isDirect: Boolean? = null - if (historyVisibility != null) { - val contentMap = mapOf("history_visibility" to historyVisibility) + /** + * Extra keys to be added to the content of the m.room.create. + * The server will clobber the following keys: creator. + * Future versions of the specification may allow the server to clobber other keys. + */ + var creationContent: Any? = null - val historyVisibilityEvent = Event( - type = EventType.STATE_ROOM_HISTORY_VISIBILITY, - stateKey = "", - content = contentMap.toContent()) - - return copy( - initialStates = newInitialStates.orEmpty() + historyVisibilityEvent - ) - } else { - return copy( - initialStates = newInitialStates - ) - } - } + /** + * The power level content to override in the default power level event + */ + var powerLevelContentOverride: PowerLevelsContent? = null /** * Mark as a direct message room. - * @return a modified copy of the CreateRoomParams object */ - @CheckResult - fun setDirectMessage(): CreateRoomParams { - return copy( - preset = CreateRoomPreset.PRESET_TRUSTED_PRIVATE_CHAT, - isDirect = true - ) + fun setDirectMessage() { + preset = CreateRoomPreset.PRESET_TRUSTED_PRIVATE_CHAT + isDirect = true } /** - * Tells if the created room can be a direct chat one. - * - * @return true if it is a direct chat + * Supported value: MXCRYPTO_ALGORITHM_MEGOLM */ - fun isDirect(): Boolean { - return preset == CreateRoomPreset.PRESET_TRUSTED_PRIVATE_CHAT - && isDirect == true - } + var algorithm: String? = null + private set - /** - * @return the first invited user id - */ - fun getFirstInvitedUserId(): String? { - return invitedUserIds?.firstOrNull() ?: invite3pids?.firstOrNull()?.address - } + var historyVisibility: RoomHistoryVisibility? = null - /** - * Add some ids to the room creation - * ids might be a matrix id or an email address. - * - * @param ids the participant ids to add. - * @return a modified copy of the CreateRoomParams object - */ - @CheckResult - fun addParticipantIds(hsConfig: HomeServerConnectionConfig, - userId: String, - ids: List): CreateRoomParams { - return copy( - invite3pids = (invite3pids.orEmpty() + ids - .takeIf { hsConfig.identityServerUri != null } - ?.filter { id -> Patterns.EMAIL_ADDRESS.matcher(id).matches() } - ?.map { id -> - Invite3Pid( - idServer = hsConfig.identityServerUri!!.host!!, - medium = ThreePidMedium.EMAIL, - address = id - ) - } - .orEmpty()) - .distinct(), - invitedUserIds = (invitedUserIds.orEmpty() + ids - .filter { id -> isUserId(id) } - // do not invite oneself - .filter { id -> id != userId }) - .distinct() - ) - // TODO add phonenumbers when it will be available + fun enableEncryption() { + algorithm = MXCRYPTO_ALGORITHM_MEGOLM } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/create/Invite3Pid.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/create/Invite3Pid.kt deleted file mode 100644 index 8e3386080f..0000000000 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/create/Invite3Pid.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2019 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.matrix.android.api.session.room.model.create - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass - -@JsonClass(generateAdapter = true) -data class Invite3Pid( - /** - * Required. - * The hostname+port of the identity server which should be used for third party identifier lookups. - */ - @Json(name = "id_server") - val idServer: String, - - /** - * Required. - * The kind of address being passed in the address field, for example email. - */ - val medium: String, - - /** - * Required. - * The invitee's third party identifier. - */ - val address: String -) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/identity/DefaultIdentityService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/identity/DefaultIdentityService.kt index 3f10bf791c..13c97599f7 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/identity/DefaultIdentityService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/identity/DefaultIdentityService.kt @@ -62,6 +62,7 @@ import javax.net.ssl.HttpsURLConnection @SessionScope internal class DefaultIdentityService @Inject constructor( private val identityStore: IdentityStore, + private val ensureIdentityTokenTask: EnsureIdentityTokenTask, private val getOpenIdTokenTask: GetOpenIdTokenTask, private val identityBulkLookupTask: IdentityBulkLookupTask, private val identityRegisterTask: IdentityRegisterTask, @@ -278,7 +279,7 @@ internal class DefaultIdentityService @Inject constructor( } private suspend fun lookUpInternal(canRetry: Boolean, threePids: List): List { - ensureToken() + ensureIdentityTokenTask.execute(Unit) return try { identityBulkLookupTask.execute(IdentityBulkLookupTask.Params(threePids)) @@ -295,17 +296,6 @@ internal class DefaultIdentityService @Inject constructor( } } - private suspend fun ensureToken() { - val identityData = identityStore.getIdentityData() ?: throw IdentityServiceError.NoIdentityServerConfigured - val url = identityData.identityServerUrl ?: throw IdentityServiceError.NoIdentityServerConfigured - - if (identityData.token == null) { - // Try to get a token - val token = getNewIdentityServerToken(url) - identityStore.setToken(token) - } - } - private suspend fun getNewIdentityServerToken(url: String): String { val api = retrofitFactory.create(unauthenticatedOkHttpClient, url).create(IdentityAuthAPI::class.java) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/identity/EnsureIdentityToken.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/identity/EnsureIdentityToken.kt new file mode 100644 index 0000000000..e727cd69bc --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/identity/EnsureIdentityToken.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2020 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.matrix.android.internal.session.identity + +import dagger.Lazy +import im.vector.matrix.android.api.session.identity.IdentityServiceError +import im.vector.matrix.android.internal.di.UnauthenticatedWithCertificate +import im.vector.matrix.android.internal.network.RetrofitFactory +import im.vector.matrix.android.internal.session.identity.data.IdentityStore +import im.vector.matrix.android.internal.session.openid.GetOpenIdTokenTask +import im.vector.matrix.android.internal.task.Task +import okhttp3.OkHttpClient +import javax.inject.Inject + +internal interface EnsureIdentityTokenTask : Task + +internal class DefaultEnsureIdentityTokenTask @Inject constructor( + private val identityStore: IdentityStore, + private val retrofitFactory: RetrofitFactory, + @UnauthenticatedWithCertificate + private val unauthenticatedOkHttpClient: Lazy, + private val getOpenIdTokenTask: GetOpenIdTokenTask, + private val identityRegisterTask: IdentityRegisterTask +) : EnsureIdentityTokenTask { + + override suspend fun execute(params: Unit) { + val identityData = identityStore.getIdentityData() ?: throw IdentityServiceError.NoIdentityServerConfigured + val url = identityData.identityServerUrl ?: throw IdentityServiceError.NoIdentityServerConfigured + + if (identityData.token == null) { + // Try to get a token + val token = getNewIdentityServerToken(url) + identityStore.setToken(token) + } + } + + private suspend fun getNewIdentityServerToken(url: String): String { + val api = retrofitFactory.create(unauthenticatedOkHttpClient, url).create(IdentityAuthAPI::class.java) + + val openIdToken = getOpenIdTokenTask.execute(Unit) + val token = identityRegisterTask.execute(IdentityRegisterTask.Params(api, openIdToken)) + + return token.token + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/identity/IdentityModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/identity/IdentityModule.kt index 9f902f79f1..79160b8c59 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/identity/IdentityModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/identity/IdentityModule.kt @@ -78,6 +78,9 @@ internal abstract class IdentityModule { @Binds abstract fun bindIdentityStore(store: RealmIdentityStore): IdentityStore + @Binds + abstract fun bindEnsureIdentityTokenTask(task: DefaultEnsureIdentityTokenTask): EnsureIdentityTokenTask + @Binds abstract fun bindIdentityPingTask(task: DefaultIdentityPingTask): IdentityPingTask diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAPI.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAPI.kt index 59fc0efbc0..fd16b1891e 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAPI.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAPI.kt @@ -18,9 +18,6 @@ package im.vector.matrix.android.internal.session.room import im.vector.matrix.android.api.session.events.model.Content import im.vector.matrix.android.api.session.events.model.Event -import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams -import im.vector.matrix.android.api.session.room.model.create.CreateRoomResponse -import im.vector.matrix.android.api.session.room.model.create.JoinRoomResponse import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoomsParams import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoomsResponse import im.vector.matrix.android.api.session.room.model.thirdparty.ThirdPartyProtocol @@ -28,9 +25,13 @@ import im.vector.matrix.android.api.util.JsonDict import im.vector.matrix.android.internal.network.NetworkConstants import im.vector.matrix.android.internal.session.room.alias.AddRoomAliasBody import im.vector.matrix.android.internal.session.room.alias.RoomAliasDescription +import im.vector.matrix.android.internal.session.room.create.CreateRoomBody +import im.vector.matrix.android.internal.session.room.create.CreateRoomResponse +import im.vector.matrix.android.internal.session.room.create.JoinRoomResponse import im.vector.matrix.android.internal.session.room.membership.RoomMembersResponse import im.vector.matrix.android.internal.session.room.membership.admin.UserIdAndReason import im.vector.matrix.android.internal.session.room.membership.joining.InviteBody +import im.vector.matrix.android.internal.session.room.membership.threepid.ThreePidInviteBody import im.vector.matrix.android.internal.session.room.relation.RelationsResponse import im.vector.matrix.android.internal.session.room.reporting.ReportContentBody import im.vector.matrix.android.internal.session.room.send.SendResponse @@ -79,7 +80,7 @@ internal interface RoomAPI { */ @Headers("CONNECT_TIMEOUT:60000", "READ_TIMEOUT:60000", "WRITE_TIMEOUT:60000") @POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "createRoom") - fun createRoom(@Body param: CreateRoomParams): Call + fun createRoom(@Body param: CreateRoomBody): Call /** * Get a list of messages starting from a reference. @@ -170,6 +171,14 @@ internal interface RoomAPI { @POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "rooms/{roomId}/invite") fun invite(@Path("roomId") roomId: String, @Body body: InviteBody): Call + /** + * Invite a user to a room, using a ThreePid + * Ref: https://matrix.org/docs/spec/client_server/r0.6.1#id101 + * @param roomId Required. The room identifier (not alias) to which to invite the user. + */ + @POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "rooms/{roomId}/invite") + fun invite3pid(@Path("roomId") roomId: String, @Body body: ThreePidInviteBody): Call + /** * Send a generic state events * diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt index 7fa9c1526a..3eb5427b70 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt @@ -44,6 +44,8 @@ import im.vector.matrix.android.internal.session.room.membership.joining.InviteT import im.vector.matrix.android.internal.session.room.membership.joining.JoinRoomTask import im.vector.matrix.android.internal.session.room.membership.leaving.DefaultLeaveRoomTask import im.vector.matrix.android.internal.session.room.membership.leaving.LeaveRoomTask +import im.vector.matrix.android.internal.session.room.membership.threepid.DefaultInviteThreePidTask +import im.vector.matrix.android.internal.session.room.membership.threepid.InviteThreePidTask import im.vector.matrix.android.internal.session.room.read.DefaultMarkAllRoomsReadTask import im.vector.matrix.android.internal.session.room.read.DefaultSetReadMarkersTask import im.vector.matrix.android.internal.session.room.read.MarkAllRoomsReadTask @@ -139,6 +141,9 @@ internal abstract class RoomModule { @Binds abstract fun bindInviteTask(task: DefaultInviteTask): InviteTask + @Binds + abstract fun bindInviteThreePidTask(task: DefaultInviteThreePidTask): InviteThreePidTask + @Binds abstract fun bindJoinRoomTask(task: DefaultJoinRoomTask): JoinRoomTask diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/CreateRoomBody.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/CreateRoomBody.kt new file mode 100644 index 0000000000..7a27da3607 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/CreateRoomBody.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2020 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.matrix.android.internal.session.room.create + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import im.vector.matrix.android.api.session.events.model.Event +import im.vector.matrix.android.api.session.room.model.PowerLevelsContent +import im.vector.matrix.android.api.session.room.model.RoomDirectoryVisibility +import im.vector.matrix.android.api.session.room.model.create.CreateRoomPreset +import im.vector.matrix.android.internal.session.room.membership.threepid.ThreePidInviteBody + +/** + * Parameter to create a room + */ +@JsonClass(generateAdapter = true) +internal data class CreateRoomBody( + /** + * A public visibility indicates that the room will be shown in the published room list. + * A private visibility will hide the room from the published room list. + * Rooms default to private visibility if this key is not included. + * NB: This should not be confused with join_rules which also uses the word public. One of: ["public", "private"] + */ + @Json(name = "visibility") + val visibility: RoomDirectoryVisibility?, + + /** + * The desired room alias local part. If this is included, a room alias will be created and mapped to the newly created room. + * The alias will belong on the same homeserver which created the room. + * For example, if this was set to "foo" and sent to the homeserver "example.com" the complete room alias would be #foo:example.com. + */ + @Json(name = "room_alias_name") + val roomAliasName: String?, + + /** + * If this is included, an m.room.name event will be sent into the room to indicate the name of the room. + * See Room Events for more information on m.room.name. + */ + @Json(name = "name") + val name: String?, + + /** + * If this is included, an m.room.topic event will be sent into the room to indicate the topic for the room. + * See Room Events for more information on m.room.topic. + */ + @Json(name = "topic") + val topic: String?, + + /** + * 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. + */ + @Json(name = "invite") + val invitedUserIds: List?, + + /** + * A list of objects representing third party IDs to invite into the room. + */ + @Json(name = "invite_3pid") + val invite3pids: List?, + + /** + * Extra keys to be added to the content of the m.room.create. + * The server will clobber the following keys: creator. + * Future versions of the specification may allow the server to clobber other keys. + */ + @Json(name = "creation_content") + val creationContent: 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 presets, but gets overridden by name and topic keys. + */ + @Json(name = "initial_state") + val initialStates: List?, + + /** + * Convenience parameter for setting various default state events based on a preset. Must be either: + * private_chat => join_rules is set to invite. history_visibility is set to shared. + * trusted_private_chat => join_rules is set to invite. history_visibility is set to shared. All invitees are given the same power level as the + * room creator. + * public_chat: => join_rules is set to public. history_visibility is set to shared. + */ + @Json(name = "preset") + val preset: CreateRoomPreset?, + + /** + * This flag makes the server set the is_direct flag on the m.room.member events sent to the users in invite and invite_3pid. + * See Direct Messaging for more information. + */ + @Json(name = "is_direct") + val isDirect: Boolean?, + + /** + * The power level content to override in the default power level event + */ + @Json(name = "power_level_content_override") + val powerLevelContentOverride: PowerLevelsContent? +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/CreateRoomBodyBuilder.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/CreateRoomBodyBuilder.kt new file mode 100644 index 0000000000..23eb88bea9 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/CreateRoomBodyBuilder.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2020 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.matrix.android.internal.session.room.create + +import im.vector.matrix.android.api.session.crypto.crosssigning.CrossSigningService +import im.vector.matrix.android.api.session.events.model.Event +import im.vector.matrix.android.api.session.events.model.EventType +import im.vector.matrix.android.api.session.events.model.toContent +import im.vector.matrix.android.api.session.identity.IdentityServiceError +import im.vector.matrix.android.api.session.identity.toMedium +import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams +import im.vector.matrix.android.internal.crypto.DeviceListManager +import im.vector.matrix.android.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM +import im.vector.matrix.android.internal.di.AuthenticatedIdentity +import im.vector.matrix.android.internal.network.token.AccessTokenProvider +import im.vector.matrix.android.internal.session.identity.EnsureIdentityTokenTask +import im.vector.matrix.android.internal.session.identity.data.IdentityStore +import im.vector.matrix.android.internal.session.identity.data.getIdentityServerUrlWithoutProtocol +import im.vector.matrix.android.internal.session.room.membership.threepid.ThreePidInviteBody +import java.security.InvalidParameterException +import javax.inject.Inject + +internal class CreateRoomBodyBuilder @Inject constructor( + private val ensureIdentityTokenTask: EnsureIdentityTokenTask, + private val crossSigningService: CrossSigningService, + private val deviceListManager: DeviceListManager, + private val identityStore: IdentityStore, + @AuthenticatedIdentity + private val accessTokenProvider: AccessTokenProvider +) { + + suspend fun build(params: CreateRoomParams): CreateRoomBody { + val invite3pids = params.invite3pids + .takeIf { it.isNotEmpty() } + .let { + // This can throw Exception if Identity server is not configured + ensureIdentityTokenTask.execute(Unit) + + val identityServerUrlWithoutProtocol = identityStore.getIdentityServerUrlWithoutProtocol() + ?: throw IdentityServiceError.NoIdentityServerConfigured + val identityServerAccessToken = accessTokenProvider.getToken() ?: throw IdentityServiceError.NoIdentityServerConfigured + + params.invite3pids.map { + ThreePidInviteBody( + id_server = identityServerUrlWithoutProtocol, + id_access_token = identityServerAccessToken, + medium = it.toMedium(), + address = it.value + ) + } + } + + val initialStates = listOfNotNull( + buildEncryptionWithAlgorithmEvent(params), + buildHistoryVisibilityEvent(params) + ) + .takeIf { it.isNotEmpty() } + + return CreateRoomBody( + visibility = params.visibility, + roomAliasName = params.roomAliasName, + name = params.name, + topic = params.topic, + invitedUserIds = params.invitedUserIds, + invite3pids = invite3pids, + creationContent = params.creationContent, + initialStates = initialStates, + preset = params.preset, + isDirect = params.isDirect, + powerLevelContentOverride = params.powerLevelContentOverride + ) + } + + private fun buildHistoryVisibilityEvent(params: CreateRoomParams): Event? { + return params.historyVisibility + ?.let { + val contentMap = mapOf("history_visibility" to it) + + Event( + type = EventType.STATE_ROOM_HISTORY_VISIBILITY, + stateKey = "", + content = contentMap.toContent()) + } + } + + /** + * Add the crypto algorithm to the room creation parameters. + */ + private suspend fun buildEncryptionWithAlgorithmEvent(params: CreateRoomParams): Event? { + if (params.algorithm == null + && canEnableEncryption(params)) { + // Enable the encryption + params.enableEncryption() + } + return params.algorithm + ?.let { + if (it != MXCRYPTO_ALGORITHM_MEGOLM) { + throw InvalidParameterException("Unsupported algorithm: $it") + } + val contentMap = mapOf("algorithm" to it) + + Event( + type = EventType.STATE_ROOM_ENCRYPTION, + stateKey = "", + content = contentMap.toContent() + ) + } + } + + private suspend fun canEnableEncryption(params: CreateRoomParams): Boolean { + return (params.enableEncryptionIfInvitedUsersSupportIt + && crossSigningService.isCrossSigningVerified() + && params.invite3pids.isEmpty()) + && params.invitedUserIds.isNotEmpty() + && params.invitedUserIds.let { userIds -> + val keys = deviceListManager.downloadKeys(userIds, forceDownload = false) + + userIds.all { userId -> + keys.map[userId].let { deviceMap -> + if (deviceMap.isNullOrEmpty()) { + // A user has no device, so do not enable encryption + false + } else { + // Check that every user's device have at least one key + deviceMap.values.all { !it.keys.isNullOrEmpty() } + } + } + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/create/CreateRoomResponse.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/CreateRoomResponse.kt similarity index 89% rename from matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/create/CreateRoomResponse.kt rename to matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/CreateRoomResponse.kt index da54b344a2..62208941cc 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/create/CreateRoomResponse.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/CreateRoomResponse.kt @@ -1,5 +1,5 @@ /* - * Copyright 2019 New Vector Ltd + * Copyright (c) 2020 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. @@ -14,7 +14,7 @@ * limitations under the License. */ -package im.vector.matrix.android.api.session.room.model.create +package im.vector.matrix.android.internal.session.room.create import com.squareup.moshi.Json import com.squareup.moshi.JsonClass diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/CreateRoomTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/CreateRoomTask.kt index 2071b7736e..791091c549 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/CreateRoomTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/create/CreateRoomTask.kt @@ -17,11 +17,9 @@ package im.vector.matrix.android.internal.session.room.create import com.zhuinden.monarchy.Monarchy -import im.vector.matrix.android.api.session.crypto.crosssigning.CrossSigningService import im.vector.matrix.android.api.session.room.failure.CreateRoomFailure import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams -import im.vector.matrix.android.api.session.room.model.create.CreateRoomResponse -import im.vector.matrix.android.internal.crypto.DeviceListManager +import im.vector.matrix.android.api.session.room.model.create.CreateRoomPreset import im.vector.matrix.android.internal.database.awaitNotEmptyResult import im.vector.matrix.android.internal.database.model.RoomEntity import im.vector.matrix.android.internal.database.model.RoomEntityFields @@ -51,20 +49,15 @@ internal class DefaultCreateRoomTask @Inject constructor( private val readMarkersTask: SetReadMarkersTask, @SessionDatabase private val realmConfiguration: RealmConfiguration, - private val crossSigningService: CrossSigningService, - private val deviceListManager: DeviceListManager, + private val createRoomBodyBuilder: CreateRoomBodyBuilder, private val eventBus: EventBus ) : CreateRoomTask { override suspend fun execute(params: CreateRoomParams): String { - val createRoomParams = if (canEnableEncryption(params)) { - params.enableEncryptionWithAlgorithm() - } else { - params - } + val createRoomBody = createRoomBodyBuilder.build(params) val createRoomResponse = executeRequest(eventBus) { - apiCall = roomAPI.createRoom(createRoomParams) + apiCall = roomAPI.createRoom(createRoomBody) } 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) @@ -76,35 +69,13 @@ internal class DefaultCreateRoomTask @Inject constructor( } catch (exception: TimeoutCancellationException) { throw CreateRoomFailure.CreatedWithTimeout } - if (createRoomParams.isDirect()) { - handleDirectChatCreation(createRoomParams, roomId) + if (params.isDirect()) { + handleDirectChatCreation(params, roomId) } setReadMarkers(roomId) return roomId } - private suspend fun canEnableEncryption(params: CreateRoomParams): Boolean { - return params.enableEncryptionIfInvitedUsersSupportIt - && crossSigningService.isCrossSigningVerified() - && params.invite3pids.isNullOrEmpty() - && params.invitedUserIds?.isNotEmpty() == true - && params.invitedUserIds.let { userIds -> - val keys = deviceListManager.downloadKeys(userIds, forceDownload = false) - - userIds.all { userId -> - keys.map[userId].let { deviceMap -> - if (deviceMap.isNullOrEmpty()) { - // A user has no device, so do not enable encryption - false - } else { - // Check that every user's device have at least one key - deviceMap.values.all { !it.keys.isNullOrEmpty() } - } - } - } - } - } - private suspend fun handleDirectChatCreation(params: CreateRoomParams, roomId: String) { val otherUserId = params.getFirstInvitedUserId() ?: throw IllegalStateException("You can't create a direct room without an invitedUser") @@ -123,4 +94,21 @@ internal class DefaultCreateRoomTask @Inject constructor( val setReadMarkerParams = SetReadMarkersTask.Params(roomId, forceReadReceipt = true, forceReadMarker = true) return readMarkersTask.execute(setReadMarkerParams) } + + /** + * Tells if the created room can be a direct chat one. + * + * @return true if it is a direct chat + */ + private fun CreateRoomParams.isDirect(): Boolean { + return preset == CreateRoomPreset.PRESET_TRUSTED_PRIVATE_CHAT + && isDirect == true + } + + /** + * @return the first invited user id + */ + private fun CreateRoomParams.getFirstInvitedUserId(): String? { + return invitedUserIds.firstOrNull() ?: invite3pids.firstOrNull()?.value + } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/DefaultMembershipService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/DefaultMembershipService.kt index 8467e8b46c..f413f5c9c0 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/DefaultMembershipService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/DefaultMembershipService.kt @@ -21,6 +21,7 @@ import com.squareup.inject.assisted.Assisted import com.squareup.inject.assisted.AssistedInject import com.zhuinden.monarchy.Monarchy import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.session.identity.ThreePid import im.vector.matrix.android.api.session.room.members.MembershipService import im.vector.matrix.android.api.session.room.members.RoomMemberQueryParams import im.vector.matrix.android.api.session.room.model.Membership @@ -36,6 +37,7 @@ import im.vector.matrix.android.internal.session.room.membership.admin.Membershi import im.vector.matrix.android.internal.session.room.membership.joining.InviteTask import im.vector.matrix.android.internal.session.room.membership.joining.JoinRoomTask import im.vector.matrix.android.internal.session.room.membership.leaving.LeaveRoomTask +import im.vector.matrix.android.internal.session.room.membership.threepid.InviteThreePidTask import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.task.configureWith import im.vector.matrix.android.internal.util.fetchCopied @@ -48,6 +50,7 @@ internal class DefaultMembershipService @AssistedInject constructor( private val taskExecutor: TaskExecutor, private val loadRoomMembersTask: LoadRoomMembersTask, private val inviteTask: InviteTask, + private val inviteThreePidTask: InviteThreePidTask, private val joinTask: JoinRoomTask, private val leaveRoomTask: LeaveRoomTask, private val membershipAdminTask: MembershipAdminTask, @@ -152,6 +155,15 @@ internal class DefaultMembershipService @AssistedInject constructor( .executeBy(taskExecutor) } + override fun invite3pid(threePid: ThreePid, callback: MatrixCallback): Cancelable { + val params = InviteThreePidTask.Params(roomId, threePid) + return inviteThreePidTask + .configureWith(params) { + this.callback = callback + } + .executeBy(taskExecutor) + } + override fun join(reason: String?, viaServers: List, callback: MatrixCallback): Cancelable { val params = JoinRoomTask.Params(roomId, reason, viaServers) return joinTask diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/joining/JoinRoomTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/joining/JoinRoomTask.kt index 7467a595bc..8fb9a1f065 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/joining/JoinRoomTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/joining/JoinRoomTask.kt @@ -18,13 +18,13 @@ package im.vector.matrix.android.internal.session.room.membership.joining import im.vector.matrix.android.api.session.room.failure.JoinRoomFailure import im.vector.matrix.android.api.session.room.members.ChangeMembershipState -import im.vector.matrix.android.api.session.room.model.create.JoinRoomResponse import im.vector.matrix.android.internal.database.awaitNotEmptyResult import im.vector.matrix.android.internal.database.model.RoomEntity import im.vector.matrix.android.internal.database.model.RoomEntityFields import im.vector.matrix.android.internal.di.SessionDatabase import im.vector.matrix.android.internal.network.executeRequest import im.vector.matrix.android.internal.session.room.RoomAPI +import im.vector.matrix.android.internal.session.room.create.JoinRoomResponse import im.vector.matrix.android.internal.session.room.membership.RoomChangeMembershipStateDataSource import im.vector.matrix.android.internal.session.room.read.SetReadMarkersTask import im.vector.matrix.android.internal.task.Task diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/threepid/InviteThreePidTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/threepid/InviteThreePidTask.kt new file mode 100644 index 0000000000..25fe7b4888 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/threepid/InviteThreePidTask.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2019 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.matrix.android.internal.session.room.membership.threepid + +import im.vector.matrix.android.api.session.identity.IdentityServiceError +import im.vector.matrix.android.api.session.identity.ThreePid +import im.vector.matrix.android.api.session.identity.toMedium +import im.vector.matrix.android.internal.di.AuthenticatedIdentity +import im.vector.matrix.android.internal.network.executeRequest +import im.vector.matrix.android.internal.network.token.AccessTokenProvider +import im.vector.matrix.android.internal.session.identity.EnsureIdentityTokenTask +import im.vector.matrix.android.internal.session.identity.data.IdentityStore +import im.vector.matrix.android.internal.session.identity.data.getIdentityServerUrlWithoutProtocol +import im.vector.matrix.android.internal.session.room.RoomAPI +import im.vector.matrix.android.internal.task.Task +import org.greenrobot.eventbus.EventBus +import javax.inject.Inject + +internal interface InviteThreePidTask : Task { + data class Params( + val roomId: String, + val threePid: ThreePid + ) +} + +internal class DefaultInviteThreePidTask @Inject constructor( + private val roomAPI: RoomAPI, + private val eventBus: EventBus, + private val identityStore: IdentityStore, + private val ensureIdentityTokenTask: EnsureIdentityTokenTask, + @AuthenticatedIdentity + private val accessTokenProvider: AccessTokenProvider +) : InviteThreePidTask { + + override suspend fun execute(params: InviteThreePidTask.Params) { + ensureIdentityTokenTask.execute(Unit) + + val identityServerUrlWithoutProtocol = identityStore.getIdentityServerUrlWithoutProtocol() ?: throw IdentityServiceError.NoIdentityServerConfigured + val identityServerAccessToken = accessTokenProvider.getToken() ?: throw IdentityServiceError.NoIdentityServerConfigured + + return executeRequest(eventBus) { + val body = ThreePidInviteBody( + id_server = identityServerUrlWithoutProtocol, + id_access_token = identityServerAccessToken, + medium = params.threePid.toMedium(), + address = params.threePid.value + ) + apiCall = roomAPI.invite3pid(params.roomId, body) + } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/threepid/ThreePidInviteBody.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/threepid/ThreePidInviteBody.kt new file mode 100644 index 0000000000..23dd6bad77 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/membership/threepid/ThreePidInviteBody.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2019 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.matrix.android.internal.session.room.membership.threepid + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +internal data class ThreePidInviteBody( + /** + * Required. The hostname+port of the identity server which should be used for third party identifier lookups. + */ + @Json(name = "id_server") val id_server: String, + /** + * Required. An access token previously registered with the identity server. Servers can treat this as optional + * to distinguish between r0.5-compatible clients and this specification version. + */ + @Json(name = "id_access_token") val id_access_token: String, + /** + * Required. The kind of address being passed in the address field, for example email. + */ + @Json(name = "medium") val medium: String, + /** + * Required. The invitee's third party identifier. + */ + @Json(name = "address") val address: String +) diff --git a/vector/src/main/java/im/vector/riotx/core/contacts/ContactsDataSource.kt b/vector/src/main/java/im/vector/riotx/core/contacts/ContactsDataSource.kt new file mode 100644 index 0000000000..fd23e495b9 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/contacts/ContactsDataSource.kt @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2020 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.riotx.core.contacts + +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.provider.ContactsContract +import androidx.annotation.WorkerThread +import timber.log.Timber +import javax.inject.Inject +import kotlin.system.measureTimeMillis + +class ContactsDataSource @Inject constructor( + private val context: Context +) { + + /** + * Will return a list of contact from the contacts book of the device, with at least one email or phone. + * If both param are false, you will get en empty list. + * Note: The return list does not contain any matrixId. + */ + @WorkerThread + fun getContacts( + withEmails: Boolean, + withMsisdn: Boolean + ): List { + val map = mutableMapOf() + val contentResolver = context.contentResolver + + measureTimeMillis { + contentResolver.query( + ContactsContract.Contacts.CONTENT_URI, + arrayOf( + ContactsContract.Contacts._ID, + ContactsContract.Data.DISPLAY_NAME, + ContactsContract.Data.PHOTO_URI + ), + null, + null, + // Sort by Display name + ContactsContract.Data.DISPLAY_NAME + ) + ?.use { cursor -> + if (cursor.count > 0) { + while (cursor.moveToNext()) { + val id = cursor.getLong(ContactsContract.Contacts._ID) ?: continue + val displayName = cursor.getString(ContactsContract.Contacts.DISPLAY_NAME) ?: continue + + val mappedContactBuilder = MappedContactBuilder( + id = id, + displayName = displayName + ) + + cursor.getString(ContactsContract.Data.PHOTO_URI) + ?.let { Uri.parse(it) } + ?.let { mappedContactBuilder.photoURI = it } + + map[id] = mappedContactBuilder + } + } + } + + // Get the phone numbers + if (withMsisdn) { + contentResolver.query(ContactsContract.CommonDataKinds.Phone.CONTENT_URI, + arrayOf( + ContactsContract.CommonDataKinds.Phone.CONTACT_ID, + ContactsContract.CommonDataKinds.Phone.NUMBER + ), + null, + null, + null) + ?.use { innerCursor -> + while (innerCursor.moveToNext()) { + val mappedContactBuilder = innerCursor.getLong(ContactsContract.CommonDataKinds.Phone.CONTACT_ID) + ?.let { map[it] } + ?: continue + innerCursor.getString(ContactsContract.CommonDataKinds.Phone.NUMBER) + ?.let { + mappedContactBuilder.msisdns.add( + MappedMsisdn( + phoneNumber = it, + matrixId = null + ) + ) + } + } + } + } + + // Get Emails + if (withEmails) { + contentResolver.query( + ContactsContract.CommonDataKinds.Email.CONTENT_URI, + arrayOf( + ContactsContract.CommonDataKinds.Email.CONTACT_ID, + ContactsContract.CommonDataKinds.Email.DATA + ), + null, + null, + null) + ?.use { innerCursor -> + while (innerCursor.moveToNext()) { + // This would allow you get several email addresses + // if the email addresses were stored in an array + val mappedContactBuilder = innerCursor.getLong(ContactsContract.CommonDataKinds.Email.CONTACT_ID) + ?.let { map[it] } + ?: continue + innerCursor.getString(ContactsContract.CommonDataKinds.Email.DATA) + ?.let { + mappedContactBuilder.emails.add( + MappedEmail( + email = it, + matrixId = null + ) + ) + } + } + } + } + }.also { Timber.d("Took ${it}ms to fetch ${map.size} contact(s)") } + + return map + .values + .filter { it.emails.isNotEmpty() || it.msisdns.isNotEmpty() } + .map { it.build() } + } + + private fun Cursor.getString(column: String): String? { + return getColumnIndex(column) + .takeIf { it != -1 } + ?.let { getString(it) } + } + + private fun Cursor.getLong(column: String): Long? { + return getColumnIndex(column) + .takeIf { it != -1 } + ?.let { getLong(it) } + } +} diff --git a/vector/src/main/java/im/vector/riotx/core/contacts/MappedContact.kt b/vector/src/main/java/im/vector/riotx/core/contacts/MappedContact.kt new file mode 100644 index 0000000000..c89a3d4b01 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/contacts/MappedContact.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2020 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.riotx.core.contacts + +import android.net.Uri + +class MappedContactBuilder( + val id: Long, + val displayName: String +) { + var photoURI: Uri? = null + val msisdns = mutableListOf() + val emails = mutableListOf() + + fun build(): MappedContact { + return MappedContact( + id = id, + displayName = displayName, + photoURI = photoURI, + msisdns = msisdns, + emails = emails + ) + } +} + +data class MappedContact( + val id: Long, + val displayName: String, + val photoURI: Uri? = null, + val msisdns: List = emptyList(), + val emails: List = emptyList() +) + +data class MappedEmail( + val email: String, + val matrixId: String? +) + +data class MappedMsisdn( + val phoneNumber: String, + val matrixId: String? +) diff --git a/vector/src/main/java/im/vector/riotx/core/di/FragmentModule.kt b/vector/src/main/java/im/vector/riotx/core/di/FragmentModule.kt index 21cff188d0..8e4f95ed54 100644 --- a/vector/src/main/java/im/vector/riotx/core/di/FragmentModule.kt +++ b/vector/src/main/java/im/vector/riotx/core/di/FragmentModule.kt @@ -23,6 +23,7 @@ import dagger.Binds import dagger.Module import dagger.multibindings.IntoMap import im.vector.riotx.features.attachments.preview.AttachmentsPreviewFragment +import im.vector.riotx.features.contactsbook.ContactsBookFragment import im.vector.riotx.features.crypto.keysbackup.settings.KeysBackupSettingsFragment import im.vector.riotx.features.crypto.quads.SharedSecuredStorageKeyFragment import im.vector.riotx.features.crypto.quads.SharedSecuredStoragePassphraseFragment @@ -528,4 +529,9 @@ interface FragmentModule { @IntoMap @FragmentKey(WidgetFragment::class) fun bindWidgetFragment(fragment: WidgetFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(ContactsBookFragment::class) + fun bindPhoneBookFragment(fragment: ContactsBookFragment): Fragment } diff --git a/vector/src/main/java/im/vector/riotx/core/epoxy/profiles/ProfileMatrixItem.kt b/vector/src/main/java/im/vector/riotx/core/epoxy/profiles/ProfileMatrixItem.kt index e9f4dba7a5..b89da07984 100644 --- a/vector/src/main/java/im/vector/riotx/core/epoxy/profiles/ProfileMatrixItem.kt +++ b/vector/src/main/java/im/vector/riotx/core/epoxy/profiles/ProfileMatrixItem.kt @@ -20,6 +20,7 @@ package im.vector.riotx.core.epoxy.profiles import android.view.View 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.matrix.android.api.crypto.RoomEncryptionTrustLevel @@ -36,16 +37,21 @@ abstract class ProfileMatrixItem : VectorEpoxyModel() @EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer @EpoxyAttribute lateinit var matrixItem: MatrixItem + @EpoxyAttribute var editable: Boolean = true @EpoxyAttribute var userEncryptionTrustLevel: RoomEncryptionTrustLevel? = null @EpoxyAttribute var clickListener: View.OnClickListener? = null override fun bind(holder: Holder) { super.bind(holder) val bestName = matrixItem.getBestName() - val matrixId = matrixItem.id.takeIf { it != bestName } - holder.view.setOnClickListener(clickListener) + val matrixId = matrixItem.id + .takeIf { it != bestName } + // Special case for ThreePid fake matrix item + .takeIf { it != "@" } + holder.view.setOnClickListener(clickListener?.takeIf { editable }) holder.titleView.text = bestName holder.subtitleView.setTextOrHide(matrixId) + holder.editableView.isVisible = editable avatarRenderer.render(matrixItem, holder.avatarImageView) holder.avatarDecorationImageView.setImageResource(userEncryptionTrustLevel.toImageRes()) } @@ -55,5 +61,6 @@ abstract class ProfileMatrixItem : VectorEpoxyModel() val subtitleView by bind(R.id.matrixItemSubtitle) val avatarImageView by bind(R.id.matrixItemAvatar) val avatarDecorationImageView by bind(R.id.matrixItemAvatarDecoration) + val editableView by bind(R.id.matrixItemEditable) } } diff --git a/vector/src/main/java/im/vector/riotx/core/extensions/BasicExtensions.kt b/vector/src/main/java/im/vector/riotx/core/extensions/BasicExtensions.kt index 5bd6852e8a..99a5cb5a1a 100644 --- a/vector/src/main/java/im/vector/riotx/core/extensions/BasicExtensions.kt +++ b/vector/src/main/java/im/vector/riotx/core/extensions/BasicExtensions.kt @@ -19,6 +19,9 @@ package im.vector.riotx.core.extensions import android.os.Bundle import android.util.Patterns import androidx.fragment.app.Fragment +import com.google.i18n.phonenumbers.NumberParseException +import com.google.i18n.phonenumbers.PhoneNumberUtil +import im.vector.matrix.android.api.extensions.ensurePrefix fun Boolean.toOnOff() = if (this) "ON" else "OFF" @@ -33,3 +36,15 @@ fun T.withArgs(block: Bundle.() -> Unit) = apply { arguments = Bu * Check if a CharSequence is an email */ fun CharSequence.isEmail() = Patterns.EMAIL_ADDRESS.matcher(this).matches() + +/** + * Check if a CharSequence is a phone number + */ +fun CharSequence.isMsisdn(): Boolean { + return try { + PhoneNumberUtil.getInstance().parse(ensurePrefix("+"), null) + true + } catch (e: NumberParseException) { + false + } +} diff --git a/vector/src/main/java/im/vector/riotx/core/extensions/Iterable.kt b/vector/src/main/java/im/vector/riotx/core/extensions/Iterable.kt index 987194ea2f..b9907f8789 100644 --- a/vector/src/main/java/im/vector/riotx/core/extensions/Iterable.kt +++ b/vector/src/main/java/im/vector/riotx/core/extensions/Iterable.kt @@ -38,13 +38,13 @@ inline fun > Iterable.lastMinBy(selector: (T) -> R): T? /** * Call each for each item, and between between each items */ -inline fun Collection.join(each: (T) -> Unit, between: (T) -> Unit) { +inline fun Collection.join(each: (Int, T) -> Unit, between: (Int, T) -> Unit) { val lastIndex = size - 1 forEachIndexed { idx, t -> - each(t) + each(idx, t) if (idx != lastIndex) { - between(t) + between(idx, t) } } } diff --git a/vector/src/main/java/im/vector/riotx/core/utils/PermissionsTools.kt b/vector/src/main/java/im/vector/riotx/core/utils/PermissionsTools.kt index 360a5efccc..6f081d52de 100644 --- a/vector/src/main/java/im/vector/riotx/core/utils/PermissionsTools.kt +++ b/vector/src/main/java/im/vector/riotx/core/utils/PermissionsTools.kt @@ -68,6 +68,7 @@ const val PERMISSION_REQUEST_CODE_DOWNLOAD_FILE = 575 const val PERMISSION_REQUEST_CODE_PICK_ATTACHMENT = 576 const val PERMISSION_REQUEST_CODE_INCOMING_URI = 577 const val PERMISSION_REQUEST_CODE_PREVIEW_FRAGMENT = 578 +const val PERMISSION_REQUEST_CODE_READ_CONTACTS = 579 /** * Log the used permissions statuses. diff --git a/vector/src/main/java/im/vector/riotx/features/command/CommandParser.kt b/vector/src/main/java/im/vector/riotx/features/command/CommandParser.kt index 7c32a34aff..2b38a1ac25 100644 --- a/vector/src/main/java/im/vector/riotx/features/command/CommandParser.kt +++ b/vector/src/main/java/im/vector/riotx/features/command/CommandParser.kt @@ -17,6 +17,9 @@ package im.vector.riotx.features.command import im.vector.matrix.android.api.MatrixPatterns +import im.vector.matrix.android.api.session.identity.ThreePid +import im.vector.riotx.core.extensions.isEmail +import im.vector.riotx.core.extensions.isMsisdn import timber.log.Timber object CommandParser { @@ -139,15 +142,24 @@ object CommandParser { if (messageParts.size >= 2) { val userId = messageParts[1] - if (MatrixPatterns.isUserId(userId)) { - ParsedCommand.Invite( - userId, - textMessage.substring(Command.INVITE.length + userId.length) - .trim() - .takeIf { it.isNotBlank() } - ) - } else { - ParsedCommand.ErrorSyntax(Command.INVITE) + when { + MatrixPatterns.isUserId(userId) -> { + ParsedCommand.Invite( + userId, + textMessage.substring(Command.INVITE.length + userId.length) + .trim() + .takeIf { it.isNotBlank() } + ) + } + userId.isEmail() -> { + ParsedCommand.Invite3Pid(ThreePid.Email(userId)) + } + userId.isMsisdn() -> { + ParsedCommand.Invite3Pid(ThreePid.Msisdn(userId)) + } + else -> { + ParsedCommand.ErrorSyntax(Command.INVITE) + } } } else { ParsedCommand.ErrorSyntax(Command.INVITE) diff --git a/vector/src/main/java/im/vector/riotx/features/command/ParsedCommand.kt b/vector/src/main/java/im/vector/riotx/features/command/ParsedCommand.kt index 44ad2265e1..041da3dcac 100644 --- a/vector/src/main/java/im/vector/riotx/features/command/ParsedCommand.kt +++ b/vector/src/main/java/im/vector/riotx/features/command/ParsedCommand.kt @@ -16,6 +16,8 @@ package im.vector.riotx.features.command +import im.vector.matrix.android.api.session.identity.ThreePid + /** * Represent a parsed command */ @@ -41,6 +43,7 @@ sealed class ParsedCommand { class UnbanUser(val userId: String, val reason: String?) : ParsedCommand() class SetUserPowerLevel(val userId: String, val powerLevel: Int?) : ParsedCommand() class Invite(val userId: String, val reason: String?) : ParsedCommand() + class Invite3Pid(val threePid: ThreePid) : ParsedCommand() class JoinRoom(val roomAlias: String, val reason: String?) : ParsedCommand() class PartRoom(val roomAlias: String, val reason: String?) : ParsedCommand() class ChangeTopic(val topic: String) : ParsedCommand() diff --git a/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactDetailItem.kt b/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactDetailItem.kt new file mode 100644 index 0000000000..8615838571 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactDetailItem.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2020 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.riotx.features.contactsbook + +import android.widget.TextView +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.riotx.R +import im.vector.riotx.core.epoxy.ClickListener +import im.vector.riotx.core.epoxy.VectorEpoxyHolder +import im.vector.riotx.core.epoxy.VectorEpoxyModel +import im.vector.riotx.core.epoxy.onClick +import im.vector.riotx.core.extensions.setTextOrHide + +@EpoxyModelClass(layout = R.layout.item_contact_detail) +abstract class ContactDetailItem : VectorEpoxyModel() { + + @EpoxyAttribute lateinit var threePid: String + @EpoxyAttribute var matrixId: String? = null + @EpoxyAttribute var clickListener: ClickListener? = null + + override fun bind(holder: Holder) { + super.bind(holder) + holder.view.onClick(clickListener) + holder.nameView.text = threePid + holder.matrixIdView.setTextOrHide(matrixId) + } + + class Holder : VectorEpoxyHolder() { + val nameView by bind(R.id.contactDetailName) + val matrixIdView by bind(R.id.contactDetailMatrixId) + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactItem.kt b/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactItem.kt new file mode 100644 index 0000000000..9a6bf8f144 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactItem.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2020 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.riotx.features.contactsbook + +import android.widget.ImageView +import android.widget.TextView +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.riotx.R +import im.vector.riotx.core.contacts.MappedContact +import im.vector.riotx.core.epoxy.VectorEpoxyHolder +import im.vector.riotx.core.epoxy.VectorEpoxyModel +import im.vector.riotx.features.home.AvatarRenderer + +@EpoxyModelClass(layout = R.layout.item_contact_main) +abstract class ContactItem : VectorEpoxyModel() { + + @EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer + @EpoxyAttribute lateinit var mappedContact: MappedContact + + override fun bind(holder: Holder) { + super.bind(holder) + // If name is empty, use userId as name and force it being centered + holder.nameView.text = mappedContact.displayName + avatarRenderer.render(mappedContact, holder.avatarImageView) + } + + class Holder : VectorEpoxyHolder() { + val nameView by bind(R.id.contactDisplayName) + val avatarImageView by bind(R.id.contactAvatar) + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactsBookAction.kt b/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactsBookAction.kt new file mode 100644 index 0000000000..001630d398 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactsBookAction.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2020 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.riotx.features.contactsbook + +import im.vector.riotx.core.platform.VectorViewModelAction + +sealed class ContactsBookAction : VectorViewModelAction { + data class FilterWith(val filter: String) : ContactsBookAction() + data class OnlyBoundContacts(val onlyBoundContacts: Boolean) : ContactsBookAction() +} diff --git a/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactsBookController.kt b/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactsBookController.kt new file mode 100644 index 0000000000..796ed0d80c --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactsBookController.kt @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2020 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.riotx.features.contactsbook + +import com.airbnb.epoxy.EpoxyController +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.Uninitialized +import im.vector.matrix.android.api.session.identity.ThreePid +import im.vector.riotx.R +import im.vector.riotx.core.contacts.MappedContact +import im.vector.riotx.core.epoxy.errorWithRetryItem +import im.vector.riotx.core.epoxy.loadingItem +import im.vector.riotx.core.epoxy.noResultItem +import im.vector.riotx.core.error.ErrorFormatter +import im.vector.riotx.core.resources.StringProvider +import im.vector.riotx.features.home.AvatarRenderer +import javax.inject.Inject + +class ContactsBookController @Inject constructor( + private val stringProvider: StringProvider, + private val avatarRenderer: AvatarRenderer, + private val errorFormatter: ErrorFormatter) : EpoxyController() { + + private var state: ContactsBookViewState? = null + + var callback: Callback? = null + + init { + requestModelBuild() + } + + fun setData(state: ContactsBookViewState) { + this.state = state + requestModelBuild() + } + + override fun buildModels() { + val currentState = state ?: return + val hasSearch = currentState.searchTerm.isNotEmpty() + when (val asyncMappedContacts = currentState.mappedContacts) { + is Uninitialized -> renderEmptyState(false) + is Loading -> renderLoading() + is Success -> renderSuccess(currentState.filteredMappedContacts, hasSearch, currentState.onlyBoundContacts) + is Fail -> renderFailure(asyncMappedContacts.error) + } + } + + private fun renderLoading() { + loadingItem { + id("loading") + loadingText(stringProvider.getString(R.string.loading_contact_book)) + } + } + + private fun renderFailure(failure: Throwable) { + errorWithRetryItem { + id("error") + text(errorFormatter.toHumanReadable(failure)) + } + } + + private fun renderSuccess(mappedContacts: List, + hasSearch: Boolean, + onlyBoundContacts: Boolean) { + if (mappedContacts.isEmpty()) { + renderEmptyState(hasSearch) + } else { + renderContacts(mappedContacts, onlyBoundContacts) + } + } + + private fun renderContacts(mappedContacts: List, onlyBoundContacts: Boolean) { + for (mappedContact in mappedContacts) { + contactItem { + id(mappedContact.id) + mappedContact(mappedContact) + avatarRenderer(avatarRenderer) + } + mappedContact.emails + .forEachIndexed { index, it -> + if (onlyBoundContacts && it.matrixId == null) return@forEachIndexed + + contactDetailItem { + id("${mappedContact.id}-e-$index-${it.email}") + threePid(it.email) + matrixId(it.matrixId) + clickListener { + if (it.matrixId != null) { + callback?.onMatrixIdClick(it.matrixId) + } else { + callback?.onThreePidClick(ThreePid.Email(it.email)) + } + } + } + } + mappedContact.msisdns + .forEachIndexed { index, it -> + if (onlyBoundContacts && it.matrixId == null) return@forEachIndexed + + contactDetailItem { + id("${mappedContact.id}-m-$index-${it.phoneNumber}") + threePid(it.phoneNumber) + matrixId(it.matrixId) + clickListener { + if (it.matrixId != null) { + callback?.onMatrixIdClick(it.matrixId) + } else { + callback?.onThreePidClick(ThreePid.Msisdn(it.phoneNumber)) + } + } + } + } + } + } + + private fun renderEmptyState(hasSearch: Boolean) { + val noResultRes = if (hasSearch) { + R.string.no_result_placeholder + } else { + R.string.empty_contact_book + } + noResultItem { + id("noResult") + text(stringProvider.getString(noResultRes)) + } + } + + interface Callback { + fun onMatrixIdClick(matrixId: String) + fun onThreePidClick(threePid: ThreePid) + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactsBookFragment.kt b/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactsBookFragment.kt new file mode 100644 index 0000000000..2a2fd9fb5d --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactsBookFragment.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2020 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.riotx.features.contactsbook + +import android.os.Bundle +import android.view.View +import androidx.core.view.isVisible +import com.airbnb.mvrx.activityViewModel +import com.airbnb.mvrx.withState +import com.jakewharton.rxbinding3.widget.checkedChanges +import com.jakewharton.rxbinding3.widget.textChanges +import im.vector.matrix.android.api.session.identity.ThreePid +import im.vector.matrix.android.api.session.user.model.User +import im.vector.riotx.R +import im.vector.riotx.core.extensions.cleanup +import im.vector.riotx.core.extensions.configureWith +import im.vector.riotx.core.extensions.hideKeyboard +import im.vector.riotx.core.platform.VectorBaseFragment +import im.vector.riotx.features.userdirectory.PendingInvitee +import im.vector.riotx.features.userdirectory.UserDirectoryAction +import im.vector.riotx.features.userdirectory.UserDirectorySharedAction +import im.vector.riotx.features.userdirectory.UserDirectorySharedActionViewModel +import im.vector.riotx.features.userdirectory.UserDirectoryViewModel +import kotlinx.android.synthetic.main.fragment_contacts_book.* +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +class ContactsBookFragment @Inject constructor( + val contactsBookViewModelFactory: ContactsBookViewModel.Factory, + private val contactsBookController: ContactsBookController +) : VectorBaseFragment(), ContactsBookController.Callback { + + override fun getLayoutResId() = R.layout.fragment_contacts_book + private val viewModel: UserDirectoryViewModel by activityViewModel() + + // Use activityViewModel to avoid loading several times the data + private val contactsBookViewModel: ContactsBookViewModel by activityViewModel() + + private lateinit var sharedActionViewModel: UserDirectorySharedActionViewModel + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + sharedActionViewModel = activityViewModelProvider.get(UserDirectorySharedActionViewModel::class.java) + setupRecyclerView() + setupFilterView() + setupOnlyBoundContactsView() + setupCloseView() + } + + private fun setupOnlyBoundContactsView() { + phoneBookOnlyBoundContacts.checkedChanges() + .subscribe { + contactsBookViewModel.handle(ContactsBookAction.OnlyBoundContacts(it)) + } + .disposeOnDestroyView() + } + + private fun setupFilterView() { + phoneBookFilter + .textChanges() + .skipInitialValue() + .debounce(300, TimeUnit.MILLISECONDS) + .subscribe { + contactsBookViewModel.handle(ContactsBookAction.FilterWith(it.toString())) + } + .disposeOnDestroyView() + } + + override fun onDestroyView() { + phoneBookRecyclerView.cleanup() + contactsBookController.callback = null + super.onDestroyView() + } + + private fun setupRecyclerView() { + contactsBookController.callback = this + phoneBookRecyclerView.configureWith(contactsBookController) + } + + private fun setupCloseView() { + phoneBookClose.debouncedClicks { + sharedActionViewModel.post(UserDirectorySharedAction.GoBack) + } + } + + override fun invalidate() = withState(contactsBookViewModel) { state -> + phoneBookOnlyBoundContacts.isVisible = state.isBoundRetrieved + contactsBookController.setData(state) + } + + override fun onMatrixIdClick(matrixId: String) { + view?.hideKeyboard() + viewModel.handle(UserDirectoryAction.SelectPendingInvitee(PendingInvitee.UserPendingInvitee(User(matrixId)))) + sharedActionViewModel.post(UserDirectorySharedAction.GoBack) + } + + override fun onThreePidClick(threePid: ThreePid) { + view?.hideKeyboard() + viewModel.handle(UserDirectoryAction.SelectPendingInvitee(PendingInvitee.ThreePidPendingInvitee(threePid))) + sharedActionViewModel.post(UserDirectorySharedAction.GoBack) + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactsBookViewModel.kt b/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactsBookViewModel.kt new file mode 100644 index 0000000000..3eb6b165b8 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactsBookViewModel.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2020 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.riotx.features.contactsbook + +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.viewModelScope +import com.airbnb.mvrx.ActivityViewModelContext +import com.airbnb.mvrx.FragmentViewModelContext +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.MvRxViewModelFactory +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.ViewModelContext +import com.squareup.inject.assisted.Assisted +import com.squareup.inject.assisted.AssistedInject +import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.session.Session +import im.vector.matrix.android.api.session.identity.FoundThreePid +import im.vector.matrix.android.api.session.identity.ThreePid +import im.vector.riotx.core.contacts.ContactsDataSource +import im.vector.riotx.core.contacts.MappedContact +import im.vector.riotx.core.extensions.exhaustive +import im.vector.riotx.core.platform.EmptyViewEvents +import im.vector.riotx.core.platform.VectorViewModel +import im.vector.riotx.features.createdirect.CreateDirectRoomActivity +import im.vector.riotx.features.invite.InviteUsersToRoomActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import timber.log.Timber + +private typealias PhoneBookSearch = String + +class ContactsBookViewModel @AssistedInject constructor(@Assisted + initialState: ContactsBookViewState, + private val contactsDataSource: ContactsDataSource, + private val session: Session) + : VectorViewModel(initialState) { + + @AssistedInject.Factory + interface Factory { + fun create(initialState: ContactsBookViewState): ContactsBookViewModel + } + + companion object : MvRxViewModelFactory { + + override fun create(viewModelContext: ViewModelContext, state: ContactsBookViewState): ContactsBookViewModel? { + return when (viewModelContext) { + is FragmentViewModelContext -> (viewModelContext.fragment() as ContactsBookFragment).contactsBookViewModelFactory.create(state) + is ActivityViewModelContext -> { + when (viewModelContext.activity()) { + is CreateDirectRoomActivity -> viewModelContext.activity().contactsBookViewModelFactory.create(state) + is InviteUsersToRoomActivity -> viewModelContext.activity().contactsBookViewModelFactory.create(state) + else -> error("Wrong activity or fragment") + } + } + else -> error("Wrong activity or fragment") + } + } + } + + private var allContacts: List = emptyList() + private var mappedContacts: List = emptyList() + + init { + loadContacts() + + selectSubscribe(ContactsBookViewState::searchTerm, ContactsBookViewState::onlyBoundContacts) { _, _ -> + updateFilteredMappedContacts() + } + } + + private fun loadContacts() { + setState { + copy( + mappedContacts = Loading() + ) + } + + viewModelScope.launch(Dispatchers.IO) { + allContacts = contactsDataSource.getContacts( + withEmails = true, + // Do not handle phone numbers for the moment + withMsisdn = false + ) + mappedContacts = allContacts + + setState { + copy( + mappedContacts = Success(allContacts) + ) + } + + performLookup(allContacts) + updateFilteredMappedContacts() + } + } + + private fun performLookup(data: List) { + viewModelScope.launch { + val threePids = data.flatMap { contact -> + contact.emails.map { ThreePid.Email(it.email) } + + contact.msisdns.map { ThreePid.Msisdn(it.phoneNumber) } + } + session.identityService().lookUp(threePids, object : MatrixCallback> { + override fun onFailure(failure: Throwable) { + // Ignore + Timber.w(failure, "Unable to perform the lookup") + } + + override fun onSuccess(data: List) { + mappedContacts = allContacts.map { contactModel -> + contactModel.copy( + emails = contactModel.emails.map { email -> + email.copy( + matrixId = data + .firstOrNull { foundThreePid -> foundThreePid.threePid.value == email.email } + ?.matrixId + ) + }, + msisdns = contactModel.msisdns.map { msisdn -> + msisdn.copy( + matrixId = data + .firstOrNull { foundThreePid -> foundThreePid.threePid.value == msisdn.phoneNumber } + ?.matrixId + ) + } + ) + } + + setState { + copy( + isBoundRetrieved = true + ) + } + + updateFilteredMappedContacts() + } + }) + } + } + + private fun updateFilteredMappedContacts() = withState { state -> + val filteredMappedContacts = mappedContacts + .filter { it.displayName.contains(state.searchTerm, true) } + .filter { contactModel -> + !state.onlyBoundContacts + || contactModel.emails.any { it.matrixId != null } || contactModel.msisdns.any { it.matrixId != null } + } + + setState { + copy( + filteredMappedContacts = filteredMappedContacts + ) + } + } + + override fun handle(action: ContactsBookAction) { + when (action) { + is ContactsBookAction.FilterWith -> handleFilterWith(action) + is ContactsBookAction.OnlyBoundContacts -> handleOnlyBoundContacts(action) + }.exhaustive + } + + private fun handleOnlyBoundContacts(action: ContactsBookAction.OnlyBoundContacts) { + setState { + copy( + onlyBoundContacts = action.onlyBoundContacts + ) + } + } + + private fun handleFilterWith(action: ContactsBookAction.FilterWith) { + setState { + copy( + searchTerm = action.filter + ) + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactsBookViewState.kt b/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactsBookViewState.kt new file mode 100644 index 0000000000..8f59403d6a --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/contactsbook/ContactsBookViewState.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2020 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.riotx.features.contactsbook + +import com.airbnb.mvrx.Async +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.MvRxState +import im.vector.riotx.core.contacts.MappedContact + +data class ContactsBookViewState( + // All the contacts on the phone + val mappedContacts: Async> = Loading(), + // Use to filter contacts by display name + val searchTerm: String = "", + // Tru to display only bound contacts with their bound 2pid + val onlyBoundContacts: Boolean = false, + // All contacts, filtered by searchTerm and onlyBoundContacts + val filteredMappedContacts: List = emptyList(), + // True when the identity service has return some data + val isBoundRetrieved: Boolean = false +) : MvRxState diff --git a/vector/src/main/java/im/vector/riotx/features/createdirect/CreateDirectRoomAction.kt b/vector/src/main/java/im/vector/riotx/features/createdirect/CreateDirectRoomAction.kt index f995f82ff7..fad36cc281 100644 --- a/vector/src/main/java/im/vector/riotx/features/createdirect/CreateDirectRoomAction.kt +++ b/vector/src/main/java/im/vector/riotx/features/createdirect/CreateDirectRoomAction.kt @@ -16,9 +16,9 @@ package im.vector.riotx.features.createdirect -import im.vector.matrix.android.api.session.user.model.User import im.vector.riotx.core.platform.VectorViewModelAction +import im.vector.riotx.features.userdirectory.PendingInvitee sealed class CreateDirectRoomAction : VectorViewModelAction { - data class CreateRoomAndInviteSelectedUsers(val selectedUsers: Set) : CreateDirectRoomAction() + data class CreateRoomAndInviteSelectedUsers(val invitees: Set) : CreateDirectRoomAction() } diff --git a/vector/src/main/java/im/vector/riotx/features/createdirect/CreateDirectRoomActivity.kt b/vector/src/main/java/im/vector/riotx/features/createdirect/CreateDirectRoomActivity.kt index ef3e9bdeff..72244d1c94 100644 --- a/vector/src/main/java/im/vector/riotx/features/createdirect/CreateDirectRoomActivity.kt +++ b/vector/src/main/java/im/vector/riotx/features/createdirect/CreateDirectRoomActivity.kt @@ -35,8 +35,15 @@ import im.vector.riotx.core.di.ScreenComponent import im.vector.riotx.core.error.ErrorFormatter import im.vector.riotx.core.extensions.addFragment import im.vector.riotx.core.extensions.addFragmentToBackstack +import im.vector.riotx.core.extensions.exhaustive import im.vector.riotx.core.platform.SimpleFragmentActivity import im.vector.riotx.core.platform.WaitingViewData +import im.vector.riotx.core.utils.PERMISSIONS_FOR_MEMBERS_SEARCH +import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_READ_CONTACTS +import im.vector.riotx.core.utils.allGranted +import im.vector.riotx.core.utils.checkPermissions +import im.vector.riotx.features.contactsbook.ContactsBookFragment +import im.vector.riotx.features.contactsbook.ContactsBookViewModel import im.vector.riotx.features.userdirectory.KnownUsersFragment import im.vector.riotx.features.userdirectory.KnownUsersFragmentArgs import im.vector.riotx.features.userdirectory.UserDirectoryFragment @@ -53,6 +60,7 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() { private lateinit var sharedActionViewModel: UserDirectorySharedActionViewModel @Inject lateinit var userDirectoryViewModelFactory: UserDirectoryViewModel.Factory @Inject lateinit var createDirectRoomViewModelFactory: CreateDirectRoomViewModel.Factory + @Inject lateinit var contactsBookViewModelFactory: ContactsBookViewModel.Factory @Inject lateinit var errorFormatter: ErrorFormatter override fun injectWith(injector: ScreenComponent) { @@ -68,12 +76,13 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() { .observe() .subscribe { sharedAction -> when (sharedAction) { - UserDirectorySharedAction.OpenUsersDirectory -> + UserDirectorySharedAction.OpenUsersDirectory -> addFragmentToBackstack(R.id.container, UserDirectoryFragment::class.java) - UserDirectorySharedAction.Close -> finish() - UserDirectorySharedAction.GoBack -> onBackPressed() + UserDirectorySharedAction.Close -> finish() + UserDirectorySharedAction.GoBack -> onBackPressed() is UserDirectorySharedAction.OnMenuItemSelected -> onMenuItemSelected(sharedAction) - } + UserDirectorySharedAction.OpenPhoneBook -> openPhoneBook() + }.exhaustive } .disposeOnDestroy() if (isFirstCreation()) { @@ -91,9 +100,27 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() { } } + private fun openPhoneBook() { + // Check permission first + if (checkPermissions(PERMISSIONS_FOR_MEMBERS_SEARCH, + this, + PERMISSION_REQUEST_CODE_READ_CONTACTS, + 0)) { + addFragmentToBackstack(R.id.container, ContactsBookFragment::class.java) + } + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + if (allGranted(grantResults)) { + if (requestCode == PERMISSION_REQUEST_CODE_READ_CONTACTS) { + addFragmentToBackstack(R.id.container, ContactsBookFragment::class.java) + } + } + } + private fun onMenuItemSelected(action: UserDirectorySharedAction.OnMenuItemSelected) { if (action.itemId == R.id.action_create_direct_room) { - viewModel.handle(CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers(action.selectedUsers)) + viewModel.handle(CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers(action.invitees)) } } diff --git a/vector/src/main/java/im/vector/riotx/features/createdirect/CreateDirectRoomViewModel.kt b/vector/src/main/java/im/vector/riotx/features/createdirect/CreateDirectRoomViewModel.kt index 1800759da6..319671b230 100644 --- a/vector/src/main/java/im/vector/riotx/features/createdirect/CreateDirectRoomViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/createdirect/CreateDirectRoomViewModel.kt @@ -23,9 +23,10 @@ import com.squareup.inject.assisted.Assisted import com.squareup.inject.assisted.AssistedInject import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams -import im.vector.matrix.android.api.session.user.model.User import im.vector.matrix.rx.rx +import im.vector.riotx.core.extensions.exhaustive import im.vector.riotx.core.platform.VectorViewModel +import im.vector.riotx.features.userdirectory.PendingInvitee class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted initialState: CreateDirectRoomViewState, @@ -48,16 +49,22 @@ class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted override fun handle(action: CreateDirectRoomAction) { when (action) { - is CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers -> createRoomAndInviteSelectedUsers(action.selectedUsers) + is CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers -> createRoomAndInviteSelectedUsers(action.invitees) } } - private fun createRoomAndInviteSelectedUsers(selectedUsers: Set) { - val roomParams = CreateRoomParams( - invitedUserIds = selectedUsers.map { it.userId } - ) - .setDirectMessage() - .enableEncryptionIfInvitedUsersSupportIt() + private fun createRoomAndInviteSelectedUsers(invitees: Set) { + val roomParams = CreateRoomParams() + .apply { + invitees.forEach { + when (it) { + is PendingInvitee.UserPendingInvitee -> invitedUserIds.add(it.user.userId) + is PendingInvitee.ThreePidPendingInvitee -> invite3pids.add(it.threePid) + }.exhaustive + } + setDirectMessage() + enableEncryptionIfInvitedUsersSupportIt = true + } session.rx() .createRoom(roomParams) diff --git a/vector/src/main/java/im/vector/riotx/features/crypto/verification/VerificationBottomSheetViewModel.kt b/vector/src/main/java/im/vector/riotx/features/crypto/verification/VerificationBottomSheetViewModel.kt index 9b454436d9..53c9deb296 100644 --- a/vector/src/main/java/im/vector/riotx/features/crypto/verification/VerificationBottomSheetViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/crypto/verification/VerificationBottomSheetViewModel.kt @@ -235,11 +235,12 @@ class VerificationBottomSheetViewModel @AssistedInject constructor( pendingRequest = Loading() ) } - val roomParams = CreateRoomParams( - invitedUserIds = listOf(otherUserId) - ) - .setDirectMessage() - .enableEncryptionIfInvitedUsersSupportIt() + val roomParams = CreateRoomParams() + .apply { + invitedUserIds.add(otherUserId) + setDirectMessage() + enableEncryptionIfInvitedUsersSupportIt = true + } session.createRoom(roomParams, object : MatrixCallback { override fun onSuccess(data: String) { diff --git a/vector/src/main/java/im/vector/riotx/features/home/AvatarRenderer.kt b/vector/src/main/java/im/vector/riotx/features/home/AvatarRenderer.kt index f917b5a9f9..3bf2f13d48 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/AvatarRenderer.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/AvatarRenderer.kt @@ -30,6 +30,7 @@ import com.bumptech.glide.request.target.DrawableImageViewTarget import com.bumptech.glide.request.target.Target import im.vector.matrix.android.api.session.content.ContentUrlResolver import im.vector.matrix.android.api.util.MatrixItem +import im.vector.riotx.core.contacts.MappedContact import im.vector.riotx.core.di.ActiveSessionHolder import im.vector.riotx.core.glide.GlideApp import im.vector.riotx.core.glide.GlideRequest @@ -63,6 +64,23 @@ class AvatarRenderer @Inject constructor(private val activeSessionHolder: Active DrawableImageViewTarget(imageView)) } + @UiThread + fun render(mappedContact: MappedContact, imageView: ImageView) { + // Create a Fake MatrixItem, for the placeholder + val matrixItem = MatrixItem.UserItem( + // Need an id starting with @ + id = "@${mappedContact.displayName}", + displayName = mappedContact.displayName + ) + + val placeholder = getPlaceholderDrawable(imageView.context, matrixItem) + GlideApp.with(imageView) + .load(mappedContact.photoURI) + .apply(RequestOptions.circleCropTransform()) + .placeholder(placeholder) + .into(imageView) + } + @UiThread fun render(context: Context, glideRequests: GlideRequests, diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt index 62078c3053..3c65b6281f 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt @@ -960,7 +960,7 @@ class RoomDetailFragment @Inject constructor( updateComposerText("") } is RoomDetailViewEvents.SlashCommandResultError -> { - displayCommandError(sendMessageResult.throwable.localizedMessage ?: getString(R.string.unexpected_error)) + displayCommandError(errorFormatter.toHumanReadable(sendMessageResult.throwable)) } is RoomDetailViewEvents.SlashCommandNotImplemented -> { displayCommandError(getString(R.string.not_implemented)) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt index 982448d1c1..a396152f6b 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt @@ -457,6 +457,10 @@ class RoomDetailViewModel @AssistedInject constructor( handleInviteSlashCommand(slashCommandResult) popDraft() } + is ParsedCommand.Invite3Pid -> { + handleInvite3pidSlashCommand(slashCommandResult) + popDraft() + } is ParsedCommand.SetUserPowerLevel -> { handleSetUserPowerLevel(slashCommandResult) popDraft() @@ -678,6 +682,12 @@ class RoomDetailViewModel @AssistedInject constructor( } } + private fun handleInvite3pidSlashCommand(invite: ParsedCommand.Invite3Pid) { + launchSlashCommandFlow { + room.invite3pid(invite.threePid, it) + } + } + private fun handleSetUserPowerLevel(setUserPowerLevel: ParsedCommand.SetUserPowerLevel) { val currentPowerLevelsContent = room.getStateEvent(EventType.STATE_ROOM_POWER_LEVELS) ?.content diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt index 22fd4eb5ec..72da87415c 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt @@ -50,6 +50,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me EventType.STATE_ROOM_TOPIC, EventType.STATE_ROOM_AVATAR, EventType.STATE_ROOM_MEMBER, + EventType.STATE_ROOM_THIRD_PARTY_INVITE, EventType.STATE_ROOM_ALIASES, EventType.STATE_ROOM_CANONICAL_ALIAS, EventType.STATE_ROOM_JOIN_RULES, @@ -96,8 +97,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me verificationConclusionItemFactory.create(event, highlight, callback) } - // Unhandled event types (yet) - EventType.STATE_ROOM_THIRD_PARTY_INVITE -> defaultItemFactory.create(event, highlight, callback) + // Unhandled event types else -> { // Should only happen when shouldShowHiddenEvents() settings is ON Timber.v("Type ${event.root.getClearType()} not handled") diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/NoticeEventFormatter.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/NoticeEventFormatter.kt index c2f683d5a5..032ad4fb62 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/NoticeEventFormatter.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/NoticeEventFormatter.kt @@ -32,6 +32,7 @@ import im.vector.matrix.android.api.session.room.model.RoomJoinRules import im.vector.matrix.android.api.session.room.model.RoomJoinRulesContent import im.vector.matrix.android.api.session.room.model.RoomMemberContent import im.vector.matrix.android.api.session.room.model.RoomNameContent +import im.vector.matrix.android.api.session.room.model.RoomThirdPartyInviteContent import im.vector.matrix.android.api.session.room.model.RoomTopicContent import im.vector.matrix.android.api.session.room.model.call.CallInviteContent import im.vector.matrix.android.api.session.room.model.create.RoomCreateContent @@ -63,6 +64,7 @@ class NoticeEventFormatter @Inject constructor(private val activeSessionDataSour EventType.STATE_ROOM_TOPIC -> formatRoomTopicEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) EventType.STATE_ROOM_AVATAR -> formatRoomAvatarEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) EventType.STATE_ROOM_MEMBER -> formatRoomMemberEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) + EventType.STATE_ROOM_THIRD_PARTY_INVITE -> formatRoomThirdPartyInvite(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) EventType.STATE_ROOM_ALIASES -> formatRoomAliasesEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) EventType.STATE_ROOM_CANONICAL_ALIAS -> formatRoomCanonicalAliasEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) EventType.STATE_ROOM_HISTORY_VISIBILITY -> formatRoomHistoryVisibilityEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName) @@ -156,6 +158,7 @@ class NoticeEventFormatter @Inject constructor(private val activeSessionDataSour EventType.STATE_ROOM_TOPIC -> formatRoomTopicEvent(event, senderName) EventType.STATE_ROOM_AVATAR -> formatRoomAvatarEvent(event, senderName) EventType.STATE_ROOM_MEMBER -> formatRoomMemberEvent(event, senderName) + EventType.STATE_ROOM_THIRD_PARTY_INVITE -> formatRoomThirdPartyInvite(event, senderName) EventType.STATE_ROOM_HISTORY_VISIBILITY -> formatRoomHistoryVisibilityEvent(event, senderName) EventType.CALL_INVITE, EventType.CALL_HANGUP, @@ -254,6 +257,31 @@ class NoticeEventFormatter @Inject constructor(private val activeSessionDataSour } } + private fun formatRoomThirdPartyInvite(event: Event, senderName: String?): CharSequence? { + val content = event.getClearContent().toModel() + val prevContent = event.resolvedPrevContent()?.toModel() + + return when { + prevContent != null -> { + // Revoke case + if (event.isSentByCurrentUser()) { + sp.getString(R.string.notice_room_third_party_revoked_invite_by_you, prevContent.displayName) + } else { + sp.getString(R.string.notice_room_third_party_revoked_invite, senderName, prevContent.displayName) + } + } + content != null -> { + // Invitation case + if (event.isSentByCurrentUser()) { + sp.getString(R.string.notice_room_third_party_invite_by_you, content.displayName) + } else { + sp.getString(R.string.notice_room_third_party_invite, senderName, content.displayName) + } + } + else -> null + } + } + private fun formatCallEvent(type: String, event: Event, senderName: String?): CharSequence? { return when (type) { EventType.CALL_INVITE -> { diff --git a/vector/src/main/java/im/vector/riotx/features/invite/InviteUsersToRoomAction.kt b/vector/src/main/java/im/vector/riotx/features/invite/InviteUsersToRoomAction.kt index 8a62935bdd..6c059c917f 100644 --- a/vector/src/main/java/im/vector/riotx/features/invite/InviteUsersToRoomAction.kt +++ b/vector/src/main/java/im/vector/riotx/features/invite/InviteUsersToRoomAction.kt @@ -16,9 +16,9 @@ package im.vector.riotx.features.invite -import im.vector.matrix.android.api.session.user.model.User import im.vector.riotx.core.platform.VectorViewModelAction +import im.vector.riotx.features.userdirectory.PendingInvitee sealed class InviteUsersToRoomAction : VectorViewModelAction { - data class InviteSelectedUsers(val selectedUsers: Set) : InviteUsersToRoomAction() + data class InviteSelectedUsers(val invitees: Set) : InviteUsersToRoomAction() } diff --git a/vector/src/main/java/im/vector/riotx/features/invite/InviteUsersToRoomActivity.kt b/vector/src/main/java/im/vector/riotx/features/invite/InviteUsersToRoomActivity.kt index 839a0767d8..af78457d96 100644 --- a/vector/src/main/java/im/vector/riotx/features/invite/InviteUsersToRoomActivity.kt +++ b/vector/src/main/java/im/vector/riotx/features/invite/InviteUsersToRoomActivity.kt @@ -30,9 +30,16 @@ import im.vector.riotx.core.di.ScreenComponent import im.vector.riotx.core.error.ErrorFormatter import im.vector.riotx.core.extensions.addFragment import im.vector.riotx.core.extensions.addFragmentToBackstack +import im.vector.riotx.core.extensions.exhaustive import im.vector.riotx.core.platform.SimpleFragmentActivity import im.vector.riotx.core.platform.WaitingViewData +import im.vector.riotx.core.utils.PERMISSIONS_FOR_MEMBERS_SEARCH +import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_READ_CONTACTS +import im.vector.riotx.core.utils.allGranted +import im.vector.riotx.core.utils.checkPermissions import im.vector.riotx.core.utils.toast +import im.vector.riotx.features.contactsbook.ContactsBookFragment +import im.vector.riotx.features.contactsbook.ContactsBookViewModel import im.vector.riotx.features.userdirectory.KnownUsersFragment import im.vector.riotx.features.userdirectory.KnownUsersFragmentArgs import im.vector.riotx.features.userdirectory.UserDirectoryFragment @@ -53,6 +60,7 @@ class InviteUsersToRoomActivity : SimpleFragmentActivity() { private lateinit var sharedActionViewModel: UserDirectorySharedActionViewModel @Inject lateinit var userDirectoryViewModelFactory: UserDirectoryViewModel.Factory @Inject lateinit var inviteUsersToRoomViewModelFactory: InviteUsersToRoomViewModel.Factory + @Inject lateinit var contactsBookViewModelFactory: ContactsBookViewModel.Factory @Inject lateinit var errorFormatter: ErrorFormatter override fun injectWith(injector: ScreenComponent) { @@ -74,7 +82,8 @@ class InviteUsersToRoomActivity : SimpleFragmentActivity() { UserDirectorySharedAction.Close -> finish() UserDirectorySharedAction.GoBack -> onBackPressed() is UserDirectorySharedAction.OnMenuItemSelected -> onMenuItemSelected(sharedAction) - } + UserDirectorySharedAction.OpenPhoneBook -> openPhoneBook() + }.exhaustive } .disposeOnDestroy() if (isFirstCreation()) { @@ -92,9 +101,27 @@ class InviteUsersToRoomActivity : SimpleFragmentActivity() { viewModel.observeViewEvents { renderInviteEvents(it) } } + private fun openPhoneBook() { + // Check permission first + if (checkPermissions(PERMISSIONS_FOR_MEMBERS_SEARCH, + this, + PERMISSION_REQUEST_CODE_READ_CONTACTS, + 0)) { + addFragmentToBackstack(R.id.container, ContactsBookFragment::class.java) + } + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + if (allGranted(grantResults)) { + if (requestCode == PERMISSION_REQUEST_CODE_READ_CONTACTS) { + addFragmentToBackstack(R.id.container, ContactsBookFragment::class.java) + } + } + } + private fun onMenuItemSelected(action: UserDirectorySharedAction.OnMenuItemSelected) { if (action.itemId == R.id.action_invite_users_to_room_invite) { - viewModel.handle(InviteUsersToRoomAction.InviteSelectedUsers(action.selectedUsers)) + viewModel.handle(InviteUsersToRoomAction.InviteSelectedUsers(action.invitees)) } } diff --git a/vector/src/main/java/im/vector/riotx/features/invite/InviteUsersToRoomViewModel.kt b/vector/src/main/java/im/vector/riotx/features/invite/InviteUsersToRoomViewModel.kt index fc2f34b7a0..2769dc56bb 100644 --- a/vector/src/main/java/im/vector/riotx/features/invite/InviteUsersToRoomViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/invite/InviteUsersToRoomViewModel.kt @@ -22,11 +22,11 @@ import com.airbnb.mvrx.ViewModelContext import com.squareup.inject.assisted.Assisted import com.squareup.inject.assisted.AssistedInject import im.vector.matrix.android.api.session.Session -import im.vector.matrix.android.api.session.user.model.User import im.vector.matrix.rx.rx import im.vector.riotx.R import im.vector.riotx.core.platform.VectorViewModel import im.vector.riotx.core.resources.StringProvider +import im.vector.riotx.features.userdirectory.PendingInvitee import io.reactivex.Observable class InviteUsersToRoomViewModel @AssistedInject constructor(@Assisted @@ -53,27 +53,30 @@ class InviteUsersToRoomViewModel @AssistedInject constructor(@Assisted override fun handle(action: InviteUsersToRoomAction) { when (action) { - is InviteUsersToRoomAction.InviteSelectedUsers -> inviteUsersToRoom(action.selectedUsers) + is InviteUsersToRoomAction.InviteSelectedUsers -> inviteUsersToRoom(action.invitees) } } - private fun inviteUsersToRoom(selectedUsers: Set) { + private fun inviteUsersToRoom(invitees: Set) { _viewEvents.post(InviteUsersToRoomViewEvents.Loading) - Observable.fromIterable(selectedUsers).flatMapCompletable { user -> - room.rx().invite(user.userId, null) + Observable.fromIterable(invitees).flatMapCompletable { user -> + when (user) { + is PendingInvitee.UserPendingInvitee -> room.rx().invite(user.user.userId, null) + is PendingInvitee.ThreePidPendingInvitee -> room.rx().invite3pid(user.threePid) + } }.subscribe( { - val successMessage = when (selectedUsers.size) { + val successMessage = when (invitees.size) { 1 -> stringProvider.getString(R.string.invitation_sent_to_one_user, - selectedUsers.first().getBestName()) + invitees.first().getBestName()) 2 -> stringProvider.getString(R.string.invitations_sent_to_two_users, - selectedUsers.first().getBestName(), - selectedUsers.last().getBestName()) + invitees.first().getBestName(), + invitees.last().getBestName()) else -> stringProvider.getQuantityString(R.plurals.invitations_sent_to_one_and_more_users, - selectedUsers.size - 1, - selectedUsers.first().getBestName(), - selectedUsers.size - 1) + invitees.size - 1, + invitees.first().getBestName(), + invitees.size - 1) } _viewEvents.post(InviteUsersToRoomViewEvents.Success(successMessage)) }, diff --git a/vector/src/main/java/im/vector/riotx/features/roomdirectory/createroom/CreateRoomViewModel.kt b/vector/src/main/java/im/vector/riotx/features/roomdirectory/createroom/CreateRoomViewModel.kt index cfe50bb2f7..b75e9444fe 100644 --- a/vector/src/main/java/im/vector/riotx/features/roomdirectory/createroom/CreateRoomViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/roomdirectory/createroom/CreateRoomViewModel.kt @@ -84,15 +84,19 @@ class CreateRoomViewModel @AssistedInject constructor(@Assisted initialState: Cr copy(asyncCreateRoomRequest = Loading()) } - val createRoomParams = CreateRoomParams( - name = state.roomName.takeIf { it.isNotBlank() }, - // Directory visibility - visibility = if (state.isInRoomDirectory) RoomDirectoryVisibility.PUBLIC else RoomDirectoryVisibility.PRIVATE, - // Public room - preset = if (state.isPublic) CreateRoomPreset.PRESET_PUBLIC_CHAT else CreateRoomPreset.PRESET_PRIVATE_CHAT - ) - // Encryption - .enableEncryptionWithAlgorithm(state.isEncrypted) + val createRoomParams = CreateRoomParams() + .apply { + name = state.roomName.takeIf { it.isNotBlank() } + // Directory visibility + visibility = if (state.isInRoomDirectory) RoomDirectoryVisibility.PUBLIC else RoomDirectoryVisibility.PRIVATE + // Public room + preset = if (state.isPublic) CreateRoomPreset.PRESET_PUBLIC_CHAT else CreateRoomPreset.PRESET_PRIVATE_CHAT + + // Encryption + if (state.isEncrypted) { + enableEncryption() + } + } session.createRoom(createRoomParams, object : MatrixCallback { override fun onSuccess(data: String) { diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListAction.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListAction.kt index 01a35b84d3..d6a63197bd 100644 --- a/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListAction.kt +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListAction.kt @@ -18,4 +18,6 @@ package im.vector.riotx.features.roomprofile.members import im.vector.riotx.core.platform.VectorViewModelAction -sealed class RoomMemberListAction : VectorViewModelAction +sealed class RoomMemberListAction : VectorViewModelAction { + data class RevokeThreePidInvite(val stateKey: String) : RoomMemberListAction() +} diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListController.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListController.kt index d0939e939e..8cf93e8589 100644 --- a/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListController.kt +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListController.kt @@ -17,7 +17,11 @@ package im.vector.riotx.features.roomprofile.members import com.airbnb.epoxy.TypedEpoxyController +import im.vector.matrix.android.api.session.events.model.Event +import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.room.model.RoomMemberSummary +import im.vector.matrix.android.api.session.room.model.RoomThirdPartyInviteContent +import im.vector.matrix.android.api.util.MatrixItem import im.vector.matrix.android.api.util.toMatrixItem import im.vector.riotx.R import im.vector.riotx.core.epoxy.dividerItem @@ -37,6 +41,7 @@ class RoomMemberListController @Inject constructor( interface Callback { fun onRoomMemberClicked(roomMember: RoomMemberSummary) + fun onThreePidInvites(event: Event) } private val dividerColor = colorProvider.getColorFromAttribute(R.attr.vctr_list_divider_color) @@ -49,15 +54,29 @@ class RoomMemberListController @Inject constructor( override fun buildModels(data: RoomMemberListViewState?) { val roomMembersByPowerLevel = data?.roomMemberSummaries?.invoke() ?: return + val threePidInvites = data.threePidInvites().orEmpty() + var threePidInvitesDone = threePidInvites.isEmpty() + for ((powerLevelCategory, roomMemberList) in roomMembersByPowerLevel) { if (roomMemberList.isEmpty()) { continue } + + if (powerLevelCategory == RoomMemberListCategories.USER && !threePidInvitesDone) { + // If there is not regular invite, display threepid invite before the regular user + buildProfileSection( + stringProvider.getString(RoomMemberListCategories.INVITE.titleRes) + ) + + buildThreePidInvites(data) + threePidInvitesDone = true + } + buildProfileSection( stringProvider.getString(powerLevelCategory.titleRes) ) roomMemberList.join( - each = { roomMember -> + each = { _, roomMember -> profileMatrixItem { id(roomMember.userId) matrixItem(roomMember.toMatrixItem()) @@ -68,13 +87,62 @@ class RoomMemberListController @Inject constructor( } } }, - between = { roomMemberBefore -> + between = { _, roomMemberBefore -> dividerItem { id("divider_${roomMemberBefore.userId}") color(dividerColor) } } ) + if (powerLevelCategory == RoomMemberListCategories.INVITE) { + // Display the threepid invite after the regular invite + dividerItem { + id("divider_threepidinvites") + color(dividerColor) + } + buildThreePidInvites(data) + threePidInvitesDone = true + } + } + + if (!threePidInvitesDone) { + // If there is not regular invite and no regular user, finally display threepid invite here + buildProfileSection( + stringProvider.getString(RoomMemberListCategories.INVITE.titleRes) + ) + + buildThreePidInvites(data) } } + + private fun buildThreePidInvites(data: RoomMemberListViewState) { + data.threePidInvites() + ?.filter { it.content.toModel() != null } + ?.join( + each = { idx, event -> + event.content.toModel() + ?.let { content -> + profileMatrixItem { + id("3pid_$idx") + matrixItem(content.toMatrixItem()) + avatarRenderer(avatarRenderer) + editable(data.actionsPermissions.canRevokeThreePidInvite) + clickListener { _ -> + callback?.onThreePidInvites(event) + } + } + } + }, + between = { idx, _ -> + dividerItem { + id("divider3_$idx") + color(dividerColor) + } + } + ) + } + + private fun RoomThirdPartyInviteContent.toMatrixItem(): MatrixItem { + return MatrixItem.UserItem("@", displayName = displayName) + } } diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListFragment.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListFragment.kt index 6bd2b5d0e3..6fe1f7ad18 100644 --- a/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListFragment.kt @@ -20,10 +20,14 @@ import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.View +import androidx.appcompat.app.AlertDialog import com.airbnb.mvrx.args import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.withState +import im.vector.matrix.android.api.session.events.model.Event +import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.room.model.RoomMemberSummary +import im.vector.matrix.android.api.session.room.model.RoomThirdPartyInviteContent import im.vector.matrix.android.api.util.toMatrixItem import im.vector.riotx.R import im.vector.riotx.core.extensions.cleanup @@ -88,6 +92,22 @@ class RoomMemberListFragment @Inject constructor( navigator.openRoomMemberProfile(roomMember.userId, roomId = roomProfileArgs.roomId, context = requireActivity()) } + override fun onThreePidInvites(event: Event) { + // Display a dialog to revoke invite if power level is high enough + val content = event.content.toModel() ?: return + val stateKey = event.stateKey ?: return + if (withState(viewModel) { it.actionsPermissions.canRevokeThreePidInvite }) { + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.three_pid_revoke_invite_dialog_title) + .setMessage(getString(R.string.three_pid_revoke_invite_dialog_content, content.displayName)) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.revoke) { _, _ -> + viewModel.handle(RoomMemberListAction.RevokeThreePidInvite(stateKey)) + } + .show() + } + } + private fun renderRoomSummary(state: RoomMemberListViewState) { state.roomSummary()?.let { roomSettingsToolbarTitleView.text = it.displayName diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListViewModel.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListViewModel.kt index f177d26725..23d5e61399 100644 --- a/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListViewModel.kt @@ -16,11 +16,13 @@ package im.vector.riotx.features.roomprofile.members +import androidx.lifecycle.viewModelScope import com.airbnb.mvrx.FragmentViewModelContext import com.airbnb.mvrx.MvRxViewModelFactory import com.airbnb.mvrx.ViewModelContext import com.squareup.inject.assisted.Assisted import com.squareup.inject.assisted.AssistedInject +import im.vector.matrix.android.api.NoOpMatrixCallback import im.vector.matrix.android.api.crypto.RoomEncryptionTrustLevel import im.vector.matrix.android.api.extensions.orFalse import im.vector.matrix.android.api.query.QueryStringValue @@ -37,12 +39,14 @@ import im.vector.matrix.rx.asObservable import im.vector.matrix.rx.mapOptional import im.vector.matrix.rx.rx import im.vector.matrix.rx.unwrap +import im.vector.riotx.core.extensions.exhaustive import im.vector.riotx.core.platform.EmptyViewEvents import im.vector.riotx.core.platform.VectorViewModel import im.vector.riotx.features.powerlevel.PowerLevelsObservableFactory import io.reactivex.Observable import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.functions.BiFunction +import kotlinx.coroutines.launch import timber.log.Timber class RoomMemberListViewModel @AssistedInject constructor(@Assisted initialState: RoomMemberListViewState, @@ -68,6 +72,7 @@ class RoomMemberListViewModel @AssistedInject constructor(@Assisted initialState init { observeRoomMemberSummaries() + observeThirdPartyInvites() observeRoomSummary() observePowerLevel() } @@ -124,7 +129,12 @@ class RoomMemberListViewModel @AssistedInject constructor(@Assisted initialState PowerLevelsObservableFactory(room).createObservable() .subscribe { val permissions = ActionPermissions( - canInvite = PowerLevelsHelper(it).isUserAbleToInvite(session.myUserId) + canInvite = PowerLevelsHelper(it).isUserAbleToInvite(session.myUserId), + canRevokeThreePidInvite = PowerLevelsHelper(it).isUserAllowedToSend( + userId = session.myUserId, + isState = true, + eventType = EventType.STATE_ROOM_THIRD_PARTY_INVITE + ) ) setState { copy(actionsPermissions = permissions) @@ -140,6 +150,13 @@ class RoomMemberListViewModel @AssistedInject constructor(@Assisted initialState } } + private fun observeThirdPartyInvites() { + room.rx().liveStateEvents(setOf(EventType.STATE_ROOM_THIRD_PARTY_INVITE)) + .execute { async -> + copy(threePidInvites = async) + } + } + private fun buildRoomMemberSummaries(powerLevelsContent: PowerLevelsContent, roomMembers: List): RoomMemberSummaries { val admins = ArrayList() val moderators = ArrayList() @@ -169,5 +186,19 @@ class RoomMemberListViewModel @AssistedInject constructor(@Assisted initialState } override fun handle(action: RoomMemberListAction) { + when (action) { + is RoomMemberListAction.RevokeThreePidInvite -> handleRevokeThreePidInvite(action) + }.exhaustive + } + + private fun handleRevokeThreePidInvite(action: RoomMemberListAction.RevokeThreePidInvite) { + viewModelScope.launch { + room.sendStateEvent( + eventType = EventType.STATE_ROOM_THIRD_PARTY_INVITE, + stateKey = action.stateKey, + body = emptyMap(), + callback = NoOpMatrixCallback() + ) + } } } diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListViewState.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListViewState.kt index ece49a178c..55fb950a8e 100644 --- a/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListViewState.kt +++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/members/RoomMemberListViewState.kt @@ -21,6 +21,7 @@ import com.airbnb.mvrx.Async import com.airbnb.mvrx.MvRxState import com.airbnb.mvrx.Uninitialized import im.vector.matrix.android.api.crypto.RoomEncryptionTrustLevel +import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.room.model.RoomMemberSummary import im.vector.matrix.android.api.session.room.model.RoomSummary import im.vector.riotx.R @@ -30,6 +31,7 @@ data class RoomMemberListViewState( val roomId: String, val roomSummary: Async = Uninitialized, val roomMemberSummaries: Async = Uninitialized, + val threePidInvites: Async> = Uninitialized, val trustLevelMap: Async> = Uninitialized, val actionsPermissions: ActionPermissions = ActionPermissions() ) : MvRxState { @@ -38,7 +40,8 @@ data class RoomMemberListViewState( } data class ActionPermissions( - val canInvite: Boolean = false + val canInvite: Boolean = false, + val canRevokeThreePidInvite: Boolean = false ) typealias RoomMemberSummaries = List>> diff --git a/vector/src/main/java/im/vector/riotx/features/userdirectory/DirectoryUsersController.kt b/vector/src/main/java/im/vector/riotx/features/userdirectory/DirectoryUsersController.kt index 9d11387fe8..d5fc34728a 100644 --- a/vector/src/main/java/im/vector/riotx/features/userdirectory/DirectoryUsersController.kt +++ b/vector/src/main/java/im/vector/riotx/features/userdirectory/DirectoryUsersController.kt @@ -60,7 +60,7 @@ class DirectoryUsersController @Inject constructor(private val session: Session, is Loading -> renderLoading() is Success -> renderSuccess( computeUsersList(asyncUsers(), currentState.directorySearchTerm), - currentState.selectedUsers.map { it.userId }, + currentState.getSelectedMatrixId(), hasSearch ) is Fail -> renderFailure(asyncUsers.error) diff --git a/vector/src/main/java/im/vector/riotx/features/userdirectory/KnownUsersController.kt b/vector/src/main/java/im/vector/riotx/features/userdirectory/KnownUsersController.kt index 7a1ad49b8c..c78368f01b 100644 --- a/vector/src/main/java/im/vector/riotx/features/userdirectory/KnownUsersController.kt +++ b/vector/src/main/java/im/vector/riotx/features/userdirectory/KnownUsersController.kt @@ -51,7 +51,7 @@ class KnownUsersController @Inject constructor(private val session: Session, fun setData(state: UserDirectoryViewState) { this.isFiltering = !state.filterKnownUsersValue.isEmpty() - val newSelection = state.selectedUsers.map { it.userId } + val newSelection = state.getSelectedMatrixId() this.users = state.knownUsers if (newSelection != selectedUsers) { this.selectedUsers = newSelection diff --git a/vector/src/main/java/im/vector/riotx/features/userdirectory/KnownUsersFragment.kt b/vector/src/main/java/im/vector/riotx/features/userdirectory/KnownUsersFragment.kt index 42dd46bd01..671c0b0ee1 100644 --- a/vector/src/main/java/im/vector/riotx/features/userdirectory/KnownUsersFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/userdirectory/KnownUsersFragment.kt @@ -63,8 +63,9 @@ class KnownUsersFragment @Inject constructor( setupRecyclerView() setupFilterView() setupAddByMatrixIdView() + setupAddFromPhoneBookView() setupCloseView() - viewModel.selectSubscribe(this, UserDirectoryViewState::selectedUsers) { + viewModel.selectSubscribe(this, UserDirectoryViewState::pendingInvitees) { renderSelectedUsers(it) } } @@ -77,7 +78,7 @@ class KnownUsersFragment @Inject constructor( override fun onPrepareOptionsMenu(menu: Menu) { withState(viewModel) { - val showMenuItem = it.selectedUsers.isNotEmpty() + val showMenuItem = it.pendingInvitees.isNotEmpty() menu.forEach { menuItem -> menuItem.isVisible = showMenuItem } @@ -86,7 +87,7 @@ class KnownUsersFragment @Inject constructor( } override fun onOptionsItemSelected(item: MenuItem): Boolean = withState(viewModel) { - sharedActionViewModel.post(UserDirectorySharedAction.OnMenuItemSelected(item.itemId, it.selectedUsers)) + sharedActionViewModel.post(UserDirectorySharedAction.OnMenuItemSelected(item.itemId, it.pendingInvitees)) return@withState true } @@ -96,6 +97,13 @@ class KnownUsersFragment @Inject constructor( } } + private fun setupAddFromPhoneBookView() { + addFromPhoneBook.debouncedClicks { + // TODO handle Permission first + sharedActionViewModel.post(UserDirectorySharedAction.OpenPhoneBook) + } + } + private fun setupRecyclerView() { knownUsersController.callback = this // Don't activate animation as we might have way to much item animation when filtering @@ -131,14 +139,14 @@ class KnownUsersFragment @Inject constructor( knownUsersController.setData(it) } - private fun renderSelectedUsers(selectedUsers: Set) { + private fun renderSelectedUsers(invitees: Set) { invalidateOptionsMenu() val currentNumberOfChips = chipGroup.childCount - val newNumberOfChips = selectedUsers.size + val newNumberOfChips = invitees.size chipGroup.removeAllViews() - selectedUsers.forEach { addChipToGroup(it) } + invitees.forEach { addChipToGroup(it) } // Scroll to the bottom when adding chips. When removing chips, do not scroll if (newNumberOfChips >= currentNumberOfChips) { @@ -148,22 +156,22 @@ class KnownUsersFragment @Inject constructor( } } - private fun addChipToGroup(user: User) { + private fun addChipToGroup(pendingInvitee: PendingInvitee) { val chip = Chip(requireContext()) chip.setChipBackgroundColorResource(android.R.color.transparent) chip.chipStrokeWidth = dimensionConverter.dpToPx(1).toFloat() - chip.text = user.getBestName() + chip.text = pendingInvitee.getBestName() chip.isClickable = true chip.isCheckable = false chip.isCloseIconVisible = true chipGroup.addView(chip) chip.setOnCloseIconClickListener { - viewModel.handle(UserDirectoryAction.RemoveSelectedUser(user)) + viewModel.handle(UserDirectoryAction.RemovePendingInvitee(pendingInvitee)) } } override fun onItemClick(user: User) { view?.hideKeyboard() - viewModel.handle(UserDirectoryAction.SelectUser(user)) + viewModel.handle(UserDirectoryAction.SelectPendingInvitee(PendingInvitee.UserPendingInvitee(user))) } } diff --git a/vector/src/main/java/im/vector/riotx/features/userdirectory/PendingInvitee.kt b/vector/src/main/java/im/vector/riotx/features/userdirectory/PendingInvitee.kt new file mode 100644 index 0000000000..c9aad1cf65 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/userdirectory/PendingInvitee.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2020 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.riotx.features.userdirectory + +import im.vector.matrix.android.api.session.identity.ThreePid +import im.vector.matrix.android.api.session.user.model.User + +sealed class PendingInvitee { + data class UserPendingInvitee(val user: User) : PendingInvitee() + data class ThreePidPendingInvitee(val threePid: ThreePid) : PendingInvitee() + + fun getBestName(): String { + return when (this) { + is UserPendingInvitee -> user.getBestName() + is ThreePidPendingInvitee -> threePid.value + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryAction.kt b/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryAction.kt index 1df3c02736..fde71cff5c 100644 --- a/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryAction.kt +++ b/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryAction.kt @@ -16,13 +16,12 @@ package im.vector.riotx.features.userdirectory -import im.vector.matrix.android.api.session.user.model.User import im.vector.riotx.core.platform.VectorViewModelAction sealed class UserDirectoryAction : VectorViewModelAction { data class FilterKnownUsers(val value: String) : UserDirectoryAction() data class SearchDirectoryUsers(val value: String) : UserDirectoryAction() object ClearFilterKnownUsers : UserDirectoryAction() - data class SelectUser(val user: User) : UserDirectoryAction() - data class RemoveSelectedUser(val user: User) : UserDirectoryAction() + data class SelectPendingInvitee(val pendingInvitee: PendingInvitee) : UserDirectoryAction() + data class RemovePendingInvitee(val pendingInvitee: PendingInvitee) : UserDirectoryAction() } diff --git a/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryFragment.kt b/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryFragment.kt index 12de191b54..a6d22dfbe3 100644 --- a/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryFragment.kt @@ -84,7 +84,7 @@ class UserDirectoryFragment @Inject constructor( override fun onItemClick(user: User) { view?.hideKeyboard() - viewModel.handle(UserDirectoryAction.SelectUser(user)) + viewModel.handle(UserDirectoryAction.SelectPendingInvitee(PendingInvitee.UserPendingInvitee(user))) sharedActionViewModel.post(UserDirectorySharedAction.GoBack) } diff --git a/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectorySharedAction.kt b/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectorySharedAction.kt index 7d1987aa4b..14270f31a7 100644 --- a/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectorySharedAction.kt +++ b/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectorySharedAction.kt @@ -16,12 +16,12 @@ package im.vector.riotx.features.userdirectory -import im.vector.matrix.android.api.session.user.model.User import im.vector.riotx.core.platform.VectorSharedAction sealed class UserDirectorySharedAction : VectorSharedAction { object OpenUsersDirectory : UserDirectorySharedAction() + object OpenPhoneBook : UserDirectorySharedAction() object Close : UserDirectorySharedAction() object GoBack : UserDirectorySharedAction() - data class OnMenuItemSelected(val itemId: Int, val selectedUsers: Set) : UserDirectorySharedAction() + data class OnMenuItemSelected(val itemId: Int, val invitees: Set) : UserDirectorySharedAction() } diff --git a/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryViewModel.kt b/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryViewModel.kt index 3111a86bf7..57ebe408c7 100644 --- a/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryViewModel.kt @@ -28,6 +28,7 @@ import com.squareup.inject.assisted.AssistedInject import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.util.toMatrixItem import im.vector.matrix.rx.rx +import im.vector.riotx.core.extensions.exhaustive import im.vector.riotx.core.extensions.toggle import im.vector.riotx.core.platform.VectorViewModel import im.vector.riotx.features.createdirect.CreateDirectRoomActivity @@ -59,9 +60,9 @@ class UserDirectoryViewModel @AssistedInject constructor(@Assisted is FragmentViewModelContext -> (viewModelContext.fragment() as KnownUsersFragment).userDirectoryViewModelFactory.create(state) is ActivityViewModelContext -> { when (viewModelContext.activity()) { - is CreateDirectRoomActivity -> viewModelContext.activity().userDirectoryViewModelFactory.create(state) + is CreateDirectRoomActivity -> viewModelContext.activity().userDirectoryViewModelFactory.create(state) is InviteUsersToRoomActivity -> viewModelContext.activity().userDirectoryViewModelFactory.create(state) - else -> error("Wrong activity or fragment") + else -> error("Wrong activity or fragment") } } else -> error("Wrong activity or fragment") @@ -79,21 +80,21 @@ class UserDirectoryViewModel @AssistedInject constructor(@Assisted is UserDirectoryAction.FilterKnownUsers -> knownUsersFilter.accept(Option.just(action.value)) is UserDirectoryAction.ClearFilterKnownUsers -> knownUsersFilter.accept(Option.empty()) is UserDirectoryAction.SearchDirectoryUsers -> directoryUsersSearch.accept(action.value) - is UserDirectoryAction.SelectUser -> handleSelectUser(action) - is UserDirectoryAction.RemoveSelectedUser -> handleRemoveSelectedUser(action) - } + is UserDirectoryAction.SelectPendingInvitee -> handleSelectUser(action) + is UserDirectoryAction.RemovePendingInvitee -> handleRemoveSelectedUser(action) + }.exhaustive } - private fun handleRemoveSelectedUser(action: UserDirectoryAction.RemoveSelectedUser) = withState { state -> - val selectedUsers = state.selectedUsers.minus(action.user) - setState { copy(selectedUsers = selectedUsers) } + private fun handleRemoveSelectedUser(action: UserDirectoryAction.RemovePendingInvitee) = withState { state -> + val selectedUsers = state.pendingInvitees.minus(action.pendingInvitee) + setState { copy(pendingInvitees = selectedUsers) } } - private fun handleSelectUser(action: UserDirectoryAction.SelectUser) = withState { state -> + private fun handleSelectUser(action: UserDirectoryAction.SelectPendingInvitee) = withState { state -> // Reset the filter asap directoryUsersSearch.accept("") - val selectedUsers = state.selectedUsers.toggle(action.user) - setState { copy(selectedUsers = selectedUsers) } + val selectedUsers = state.pendingInvitees.toggle(action.pendingInvitee) + setState { copy(pendingInvitees = selectedUsers) } } private fun observeDirectoryUsers() = withState { state -> diff --git a/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryViewState.kt b/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryViewState.kt index 52f92a9994..4d99a75fde 100644 --- a/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryViewState.kt +++ b/vector/src/main/java/im/vector/riotx/features/userdirectory/UserDirectoryViewState.kt @@ -27,11 +27,21 @@ data class UserDirectoryViewState( val excludedUserIds: Set? = null, val knownUsers: Async> = Uninitialized, val directoryUsers: Async> = Uninitialized, - val selectedUsers: Set = emptySet(), + val pendingInvitees: Set = emptySet(), val createAndInviteState: Async = Uninitialized, val directorySearchTerm: String = "", val filterKnownUsersValue: Option = Option.empty() ) : MvRxState { constructor(args: KnownUsersFragmentArgs) : this(excludedUserIds = args.excludedUserIds) + + fun getSelectedMatrixId(): List { + return pendingInvitees + .mapNotNull { + when (it) { + is PendingInvitee.UserPendingInvitee -> it.user.userId + is PendingInvitee.ThreePidPendingInvitee -> null + } + } + } } diff --git a/vector/src/main/res/layout/fragment_contacts_book.xml b/vector/src/main/res/layout/fragment_contacts_book.xml new file mode 100644 index 0000000000..eb90da1bbe --- /dev/null +++ b/vector/src/main/res/layout/fragment_contacts_book.xml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_known_users.xml b/vector/src/main/res/layout/fragment_known_users.xml index c04cf027a6..82ddea5323 100644 --- a/vector/src/main/res/layout/fragment_known_users.xml +++ b/vector/src/main/res/layout/fragment_known_users.xml @@ -123,6 +123,23 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/knownUsersFilterDivider" /> + + diff --git a/vector/src/main/res/layout/item_contact_detail.xml b/vector/src/main/res/layout/item_contact_detail.xml new file mode 100644 index 0000000000..c6a16bdb69 --- /dev/null +++ b/vector/src/main/res/layout/item_contact_detail.xml @@ -0,0 +1,46 @@ + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/item_contact_main.xml b/vector/src/main/res/layout/item_contact_main.xml new file mode 100644 index 0000000000..e9a07274b3 --- /dev/null +++ b/vector/src/main/res/layout/item_contact_main.xml @@ -0,0 +1,39 @@ + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index 32de32e094..be7602af51 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -2541,4 +2541,15 @@ Not all features in Riot are implemented in RiotX yet. Main missing (and coming Waiting for encryption history Save recovery key in + + Add from my phone book + Your phone book is empty + Phone book + Search in my contacts + Retrieving your contacts… + Your contact book is empty + Contacts book + + Revoke invite + Revoke invite to %1$s?