Merge pull request #1658 from vector-im/feature/3pid_invite

3pid invite
This commit is contained in:
Benoit Marty 2020-07-11 22:50:08 +02:00 committed by GitHub
commit eedf545409
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
71 changed files with 2177 additions and 425 deletions

View File

@ -12,6 +12,8 @@ Improvements 🙌:
- Setup server recovery banner (#1648) - Setup server recovery banner (#1648)
- Set up SSSS from security settings (#1567) - Set up SSSS from security settings (#1567)
- New lab setting to add 'unread notifications' tab to main screen - 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 🐛: Bugfix 🐛:
- Integration Manager: Wrong URL to review terms if URL in config contains path (#1606) - Integration Manager: Wrong URL to review terms if URL in config contains path (#1606)
@ -27,7 +29,7 @@ Translations 🗣:
- -
SDK API changes ⚠️: SDK API changes ⚠️:
- - CreateRoomParams has been updated
Build 🧱: Build 🧱:
- Upgrade some dependencies - Upgrade some dependencies

View File

@ -19,6 +19,7 @@ package im.vector.matrix.rx
import android.net.Uri import android.net.Uri
import im.vector.matrix.android.api.query.QueryStringValue 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.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.Room
import im.vector.matrix.android.api.session.room.members.RoomMemberQueryParams import im.vector.matrix.android.api.session.room.members.RoomMemberQueryParams
import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary
@ -71,6 +72,13 @@ class RxRoom(private val room: Room) {
} }
} }
fun liveStateEvents(eventTypes: Set<String>): Observable<List<Event>> {
return room.getStateEventsLive(eventTypes).asObservable()
.startWithCallable {
room.getStateEvents(eventTypes)
}
}
fun liveReadMarker(): Observable<Optional<String>> { fun liveReadMarker(): Observable<Optional<String>> {
return room.getReadMarkerLive().asObservable() return room.getReadMarkerLive().asObservable()
} }
@ -104,6 +112,10 @@ class RxRoom(private val room: Room) {
room.invite(userId, reason, it) room.invite(userId, reason, it)
} }
fun invite3pid(threePid: ThreePid): Completable = completableBuilder<Unit> {
room.invite3pid(threePid, it)
}
fun updateTopic(topic: String): Completable = completableBuilder<Unit> { fun updateTopic(topic: String): Completable = completableBuilder<Unit> {
room.updateTopic(topic, it) room.updateTopic(topic, it)
} }

View File

@ -65,7 +65,7 @@ class CryptoTestHelper(private val mTestHelper: CommonTestHelper) {
val aliceSession = mTestHelper.createAccount(TestConstants.USER_ALICE, defaultSessionParams) val aliceSession = mTestHelper.createAccount(TestConstants.USER_ALICE, defaultSessionParams)
val roomId = mTestHelper.doSync<String> { val roomId = mTestHelper.doSync<String> {
aliceSession.createRoom(CreateRoomParams(name = "MyRoom"), it) aliceSession.createRoom(CreateRoomParams().apply { name = "MyRoom" }, it)
} }
if (encryptedRoom) { if (encryptedRoom) {
@ -175,7 +175,7 @@ class CryptoTestHelper(private val mTestHelper: CommonTestHelper) {
} }
mTestHelper.doSync<Unit> { mTestHelper.doSync<Unit> {
samSession.joinRoom(room.roomId, null, it) samSession.joinRoom(room.roomId, null, emptyList(), it)
} }
return samSession return samSession
@ -286,9 +286,11 @@ class CryptoTestHelper(private val mTestHelper: CommonTestHelper) {
fun createDM(alice: Session, bob: Session): String { fun createDM(alice: Session, bob: Session): String {
val roomId = mTestHelper.doSync<String> { val roomId = mTestHelper.doSync<String> {
alice.createRoom( alice.createRoom(
CreateRoomParams(invitedUserIds = listOf(bob.myUserId)) CreateRoomParams().apply {
.setDirectMessage() invitedUserIds.add(bob.myUserId)
.enableEncryptionIfInvitedUsersSupportIt(), setDirectMessage()
enableEncryptionIfInvitedUsersSupportIt = true
},
it it
) )
} }

View File

@ -66,7 +66,10 @@ class KeyShareTests : InstrumentedTest {
// Create an encrypted room and add a message // Create an encrypted room and add a message
val roomId = mTestHelper.doSync<String> { val roomId = mTestHelper.doSync<String> {
aliceSession.createRoom( aliceSession.createRoom(
CreateRoomParams(RoomDirectoryVisibility.PRIVATE).enableEncryptionWithAlgorithm(true), CreateRoomParams().apply {
visibility = RoomDirectoryVisibility.PRIVATE
enableEncryption()
},
it it
) )
} }
@ -285,7 +288,7 @@ class KeyShareTests : InstrumentedTest {
mTestHelper.waitWithLatch(60_000) { latch -> mTestHelper.waitWithLatch(60_000) { latch ->
val keysBackupService = aliceSession2.cryptoService().keysBackupService() val keysBackupService = aliceSession2.cryptoService().keysBackupService()
mTestHelper.retryPeriodicallyWithLatch(latch) { mTestHelper.retryPeriodicallyWithLatch(latch) {
Log.d("#TEST", "Recovery :${ keysBackupService.getKeyBackupRecoveryKeyInfo()?.recoveryKey}") Log.d("#TEST", "Recovery :${keysBackupService.getKeyBackupRecoveryKeyInfo()?.recoveryKey}")
keysBackupService.getKeyBackupRecoveryKeyInfo()?.recoveryKey == creationInfo.recoveryKey keysBackupService.getKeyBackupRecoveryKeyInfo()?.recoveryKey == creationInfo.recoveryKey
} }
} }

View File

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

View File

@ -113,5 +113,5 @@ interface RoomService {
*/ */
fun getChangeMembershipsLive(): LiveData<Map<String, ChangeMembershipState>> fun getChangeMembershipsLive(): LiveData<Map<String, ChangeMembershipState>>
fun getExistingDirectRoomWithUser(otherUserId: String) : Room? fun getExistingDirectRoomWithUser(otherUserId: String): Room?
} }

View File

@ -18,6 +18,7 @@ package im.vector.matrix.android.api.session.room.members
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import im.vector.matrix.android.api.MatrixCallback 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.session.room.model.RoomMemberSummary
import im.vector.matrix.android.api.util.Cancelable import im.vector.matrix.android.api.util.Cancelable
@ -63,6 +64,12 @@ interface MembershipService {
reason: String? = null, reason: String? = null,
callback: MatrixCallback<Unit>): Cancelable callback: MatrixCallback<Unit>): Cancelable
/**
* Invite a user with email or phone number in the room
*/
fun invite3pid(threePid: ThreePid,
callback: MatrixCallback<Unit>): Cancelable
/** /**
* Ban a user from the room * Ban a user from the room
*/ */

View File

@ -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<PublicKeys> = 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
)

View File

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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 package im.vector.matrix.android.api.session.room.model.create
import android.util.Patterns import im.vector.matrix.android.api.session.identity.ThreePid
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.room.model.PowerLevelsContent 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.RoomDirectoryVisibility
import im.vector.matrix.android.api.session.room.model.RoomHistoryVisibility 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 im.vector.matrix.android.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM
import timber.log.Timber
/** // TODO Give a way to include other initial states
* Parameter to create a room, with facilities functions to configure it class CreateRoomParams {
*/ /**
@JsonClass(generateAdapter = true) * A public visibility indicates that the room will be shown in the published room list.
data class CreateRoomParams( * A private visibility will hide the room from the published room list.
/** * Rooms default to private visibility if this key is not included.
* A public visibility indicates that the room will be shown in the published room list. * NB: This should not be confused with join_rules which also uses the word public. One of: ["public", "private"]
* A private visibility will hide the room from the published room list. */
* Rooms default to private visibility if this key is not included. var visibility: RoomDirectoryVisibility? = null
* 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<String>? = null,
/**
* A list of objects representing third party IDs to invite into the room.
*/
@Json(name = "invite_3pid")
val invite3pids: List<Invite3Pid>? = 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<Event>? = 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
/** /**
* 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<String>()
/**
* A list of objects representing third party IDs to invite into the room.
*/
val invite3pids = mutableListOf<ThreePid>()
/**
* 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 * 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 { var enableEncryptionIfInvitedUsersSupportIt: Boolean = false
enableEncryptionIfInvitedUsersSupportIt = value
return this
}
/** /**
* Add the crypto algorithm to the room creation parameters. * 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.
* @param enable true to enable encryption. * 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
* @param algorithm the algorithm, default to [MXCRYPTO_ALGORITHM_MEGOLM], which is actually the only supported algorithm for the moment * room creator.
* @return a modified copy of the CreateRoomParams object, or this if there is no modification * public_chat: => join_rules is set to public. history_visibility is set to shared.
*/ */
@CheckResult var preset: CreateRoomPreset? = null
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
}
}
/** /**
* Force the history visibility in the room creation parameters. * 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.
* @param historyVisibility the expected history visibility, set null to remove any existing value.
* @return a modified copy of the CreateRoomParams object
*/ */
@CheckResult var isDirect: Boolean? = null
fun setHistoryVisibility(historyVisibility: RoomHistoryVisibility?): CreateRoomParams {
// Remove the existing value if any.
val newInitialStates = initialStates
?.filter { it.type != EventType.STATE_ROOM_HISTORY_VISIBILITY }
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, * The power level content to override in the default power level event
stateKey = "", */
content = contentMap.toContent()) var powerLevelContentOverride: PowerLevelsContent? = null
return copy(
initialStates = newInitialStates.orEmpty() + historyVisibilityEvent
)
} else {
return copy(
initialStates = newInitialStates
)
}
}
/** /**
* Mark as a direct message room. * Mark as a direct message room.
* @return a modified copy of the CreateRoomParams object
*/ */
@CheckResult fun setDirectMessage() {
fun setDirectMessage(): CreateRoomParams { preset = CreateRoomPreset.PRESET_TRUSTED_PRIVATE_CHAT
return copy( isDirect = true
preset = CreateRoomPreset.PRESET_TRUSTED_PRIVATE_CHAT,
isDirect = true
)
} }
/** /**
* Tells if the created room can be a direct chat one. * Supported value: MXCRYPTO_ALGORITHM_MEGOLM
*
* @return true if it is a direct chat
*/ */
fun isDirect(): Boolean { var algorithm: String? = null
return preset == CreateRoomPreset.PRESET_TRUSTED_PRIVATE_CHAT private set
&& isDirect == true
}
/** var historyVisibility: RoomHistoryVisibility? = null
* @return the first invited user id
*/
fun getFirstInvitedUserId(): String? {
return invitedUserIds?.firstOrNull() ?: invite3pids?.firstOrNull()?.address
}
/** fun enableEncryption() {
* Add some ids to the room creation algorithm = MXCRYPTO_ALGORITHM_MEGOLM
* 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<String>): 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
} }
} }

View File

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

View File

@ -62,6 +62,7 @@ import javax.net.ssl.HttpsURLConnection
@SessionScope @SessionScope
internal class DefaultIdentityService @Inject constructor( internal class DefaultIdentityService @Inject constructor(
private val identityStore: IdentityStore, private val identityStore: IdentityStore,
private val ensureIdentityTokenTask: EnsureIdentityTokenTask,
private val getOpenIdTokenTask: GetOpenIdTokenTask, private val getOpenIdTokenTask: GetOpenIdTokenTask,
private val identityBulkLookupTask: IdentityBulkLookupTask, private val identityBulkLookupTask: IdentityBulkLookupTask,
private val identityRegisterTask: IdentityRegisterTask, private val identityRegisterTask: IdentityRegisterTask,
@ -278,7 +279,7 @@ internal class DefaultIdentityService @Inject constructor(
} }
private suspend fun lookUpInternal(canRetry: Boolean, threePids: List<ThreePid>): List<FoundThreePid> { private suspend fun lookUpInternal(canRetry: Boolean, threePids: List<ThreePid>): List<FoundThreePid> {
ensureToken() ensureIdentityTokenTask.execute(Unit)
return try { return try {
identityBulkLookupTask.execute(IdentityBulkLookupTask.Params(threePids)) 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 { private suspend fun getNewIdentityServerToken(url: String): String {
val api = retrofitFactory.create(unauthenticatedOkHttpClient, url).create(IdentityAuthAPI::class.java) val api = retrofitFactory.create(unauthenticatedOkHttpClient, url).create(IdentityAuthAPI::class.java)

View File

@ -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<Unit, Unit>
internal class DefaultEnsureIdentityTokenTask @Inject constructor(
private val identityStore: IdentityStore,
private val retrofitFactory: RetrofitFactory,
@UnauthenticatedWithCertificate
private val unauthenticatedOkHttpClient: Lazy<OkHttpClient>,
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
}
}

View File

@ -78,6 +78,9 @@ internal abstract class IdentityModule {
@Binds @Binds
abstract fun bindIdentityStore(store: RealmIdentityStore): IdentityStore abstract fun bindIdentityStore(store: RealmIdentityStore): IdentityStore
@Binds
abstract fun bindEnsureIdentityTokenTask(task: DefaultEnsureIdentityTokenTask): EnsureIdentityTokenTask
@Binds @Binds
abstract fun bindIdentityPingTask(task: DefaultIdentityPingTask): IdentityPingTask abstract fun bindIdentityPingTask(task: DefaultIdentityPingTask): IdentityPingTask

View File

@ -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.Content
import im.vector.matrix.android.api.session.events.model.Event 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.PublicRoomsParams
import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoomsResponse import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoomsResponse
import im.vector.matrix.android.api.session.room.model.thirdparty.ThirdPartyProtocol 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.network.NetworkConstants
import im.vector.matrix.android.internal.session.room.alias.AddRoomAliasBody 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.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.RoomMembersResponse
import im.vector.matrix.android.internal.session.room.membership.admin.UserIdAndReason 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.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.relation.RelationsResponse
import im.vector.matrix.android.internal.session.room.reporting.ReportContentBody import im.vector.matrix.android.internal.session.room.reporting.ReportContentBody
import im.vector.matrix.android.internal.session.room.send.SendResponse 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") @Headers("CONNECT_TIMEOUT:60000", "READ_TIMEOUT:60000", "WRITE_TIMEOUT:60000")
@POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "createRoom") @POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "createRoom")
fun createRoom(@Body param: CreateRoomParams): Call<CreateRoomResponse> fun createRoom(@Body param: CreateRoomBody): Call<CreateRoomResponse>
/** /**
* Get a list of messages starting from a reference. * 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") @POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "rooms/{roomId}/invite")
fun invite(@Path("roomId") roomId: String, @Body body: InviteBody): Call<Unit> fun invite(@Path("roomId") roomId: String, @Body body: InviteBody): Call<Unit>
/**
* 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<Unit>
/** /**
* Send a generic state events * Send a generic state events
* *

View File

@ -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.joining.JoinRoomTask
import im.vector.matrix.android.internal.session.room.membership.leaving.DefaultLeaveRoomTask 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.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.DefaultMarkAllRoomsReadTask
import im.vector.matrix.android.internal.session.room.read.DefaultSetReadMarkersTask import im.vector.matrix.android.internal.session.room.read.DefaultSetReadMarkersTask
import im.vector.matrix.android.internal.session.room.read.MarkAllRoomsReadTask import im.vector.matrix.android.internal.session.room.read.MarkAllRoomsReadTask
@ -139,6 +141,9 @@ internal abstract class RoomModule {
@Binds @Binds
abstract fun bindInviteTask(task: DefaultInviteTask): InviteTask abstract fun bindInviteTask(task: DefaultInviteTask): InviteTask
@Binds
abstract fun bindInviteThreePidTask(task: DefaultInviteThreePidTask): InviteThreePidTask
@Binds @Binds
abstract fun bindJoinRoomTask(task: DefaultJoinRoomTask): JoinRoomTask abstract fun bindJoinRoomTask(task: DefaultJoinRoomTask): JoinRoomTask

View File

@ -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<String>?,
/**
* A list of objects representing third party IDs to invite into the room.
*/
@Json(name = "invite_3pid")
val invite3pids: List<ThreePidInviteBody>?,
/**
* 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<Event>?,
/**
* 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?
)

View File

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

View File

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,7 +14,7 @@
* limitations under the License. * 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.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass

View File

@ -17,11 +17,9 @@
package im.vector.matrix.android.internal.session.room.create package im.vector.matrix.android.internal.session.room.create
import com.zhuinden.monarchy.Monarchy 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.failure.CreateRoomFailure
import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams 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.CreateRoomPreset
import im.vector.matrix.android.internal.crypto.DeviceListManager
import im.vector.matrix.android.internal.database.awaitNotEmptyResult 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.RoomEntity
import im.vector.matrix.android.internal.database.model.RoomEntityFields import im.vector.matrix.android.internal.database.model.RoomEntityFields
@ -51,20 +49,15 @@ internal class DefaultCreateRoomTask @Inject constructor(
private val readMarkersTask: SetReadMarkersTask, private val readMarkersTask: SetReadMarkersTask,
@SessionDatabase @SessionDatabase
private val realmConfiguration: RealmConfiguration, private val realmConfiguration: RealmConfiguration,
private val crossSigningService: CrossSigningService, private val createRoomBodyBuilder: CreateRoomBodyBuilder,
private val deviceListManager: DeviceListManager,
private val eventBus: EventBus private val eventBus: EventBus
) : CreateRoomTask { ) : CreateRoomTask {
override suspend fun execute(params: CreateRoomParams): String { override suspend fun execute(params: CreateRoomParams): String {
val createRoomParams = if (canEnableEncryption(params)) { val createRoomBody = createRoomBodyBuilder.build(params)
params.enableEncryptionWithAlgorithm()
} else {
params
}
val createRoomResponse = executeRequest<CreateRoomResponse>(eventBus) { val createRoomResponse = executeRequest<CreateRoomResponse>(eventBus) {
apiCall = roomAPI.createRoom(createRoomParams) apiCall = roomAPI.createRoom(createRoomBody)
} }
val roomId = createRoomResponse.roomId 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) // 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) { } catch (exception: TimeoutCancellationException) {
throw CreateRoomFailure.CreatedWithTimeout throw CreateRoomFailure.CreatedWithTimeout
} }
if (createRoomParams.isDirect()) { if (params.isDirect()) {
handleDirectChatCreation(createRoomParams, roomId) handleDirectChatCreation(params, roomId)
} }
setReadMarkers(roomId) setReadMarkers(roomId)
return 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) { private suspend fun handleDirectChatCreation(params: CreateRoomParams, roomId: String) {
val otherUserId = params.getFirstInvitedUserId() val otherUserId = params.getFirstInvitedUserId()
?: throw IllegalStateException("You can't create a direct room without an invitedUser") ?: 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) val setReadMarkerParams = SetReadMarkersTask.Params(roomId, forceReadReceipt = true, forceReadMarker = true)
return readMarkersTask.execute(setReadMarkerParams) return readMarkersTask.execute(setReadMarkerParams)
} }
/**
* Tells if the created room can be a direct chat one.
*
* @return true if it is a direct chat
*/
private fun CreateRoomParams.isDirect(): Boolean {
return preset == CreateRoomPreset.PRESET_TRUSTED_PRIVATE_CHAT
&& isDirect == true
}
/**
* @return the first invited user id
*/
private fun CreateRoomParams.getFirstInvitedUserId(): String? {
return invitedUserIds.firstOrNull() ?: invite3pids.firstOrNull()?.value
}
} }

View File

@ -21,6 +21,7 @@ import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject import com.squareup.inject.assisted.AssistedInject
import com.zhuinden.monarchy.Monarchy import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.MatrixCallback 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.MembershipService
import im.vector.matrix.android.api.session.room.members.RoomMemberQueryParams import im.vector.matrix.android.api.session.room.members.RoomMemberQueryParams
import im.vector.matrix.android.api.session.room.model.Membership 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.InviteTask
import im.vector.matrix.android.internal.session.room.membership.joining.JoinRoomTask 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.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.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith import im.vector.matrix.android.internal.task.configureWith
import im.vector.matrix.android.internal.util.fetchCopied import im.vector.matrix.android.internal.util.fetchCopied
@ -48,6 +50,7 @@ internal class DefaultMembershipService @AssistedInject constructor(
private val taskExecutor: TaskExecutor, private val taskExecutor: TaskExecutor,
private val loadRoomMembersTask: LoadRoomMembersTask, private val loadRoomMembersTask: LoadRoomMembersTask,
private val inviteTask: InviteTask, private val inviteTask: InviteTask,
private val inviteThreePidTask: InviteThreePidTask,
private val joinTask: JoinRoomTask, private val joinTask: JoinRoomTask,
private val leaveRoomTask: LeaveRoomTask, private val leaveRoomTask: LeaveRoomTask,
private val membershipAdminTask: MembershipAdminTask, private val membershipAdminTask: MembershipAdminTask,
@ -152,6 +155,15 @@ internal class DefaultMembershipService @AssistedInject constructor(
.executeBy(taskExecutor) .executeBy(taskExecutor)
} }
override fun invite3pid(threePid: ThreePid, callback: MatrixCallback<Unit>): Cancelable {
val params = InviteThreePidTask.Params(roomId, threePid)
return inviteThreePidTask
.configureWith(params) {
this.callback = callback
}
.executeBy(taskExecutor)
}
override fun join(reason: String?, viaServers: List<String>, callback: MatrixCallback<Unit>): Cancelable { override fun join(reason: String?, viaServers: List<String>, callback: MatrixCallback<Unit>): Cancelable {
val params = JoinRoomTask.Params(roomId, reason, viaServers) val params = JoinRoomTask.Params(roomId, reason, viaServers)
return joinTask return joinTask

View File

@ -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.failure.JoinRoomFailure
import im.vector.matrix.android.api.session.room.members.ChangeMembershipState 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.awaitNotEmptyResult
import im.vector.matrix.android.internal.database.model.RoomEntity import im.vector.matrix.android.internal.database.model.RoomEntity
import im.vector.matrix.android.internal.database.model.RoomEntityFields import im.vector.matrix.android.internal.database.model.RoomEntityFields
import im.vector.matrix.android.internal.di.SessionDatabase import im.vector.matrix.android.internal.di.SessionDatabase
import im.vector.matrix.android.internal.network.executeRequest 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.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.membership.RoomChangeMembershipStateDataSource
import im.vector.matrix.android.internal.session.room.read.SetReadMarkersTask import im.vector.matrix.android.internal.session.room.read.SetReadMarkersTask
import im.vector.matrix.android.internal.task.Task import im.vector.matrix.android.internal.task.Task

View File

@ -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<InviteThreePidTask.Params, Unit> {
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)
}
}
}

View File

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

View File

@ -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<MappedContact> {
val map = mutableMapOf<Long, MappedContactBuilder>()
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) }
}
}

View File

@ -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<MappedMsisdn>()
val emails = mutableListOf<MappedEmail>()
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<MappedMsisdn> = emptyList(),
val emails: List<MappedEmail> = emptyList()
)
data class MappedEmail(
val email: String,
val matrixId: String?
)
data class MappedMsisdn(
val phoneNumber: String,
val matrixId: String?
)

View File

@ -23,6 +23,7 @@ import dagger.Binds
import dagger.Module import dagger.Module
import dagger.multibindings.IntoMap import dagger.multibindings.IntoMap
import im.vector.riotx.features.attachments.preview.AttachmentsPreviewFragment 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.keysbackup.settings.KeysBackupSettingsFragment
import im.vector.riotx.features.crypto.quads.SharedSecuredStorageKeyFragment import im.vector.riotx.features.crypto.quads.SharedSecuredStorageKeyFragment
import im.vector.riotx.features.crypto.quads.SharedSecuredStoragePassphraseFragment import im.vector.riotx.features.crypto.quads.SharedSecuredStoragePassphraseFragment
@ -528,4 +529,9 @@ interface FragmentModule {
@IntoMap @IntoMap
@FragmentKey(WidgetFragment::class) @FragmentKey(WidgetFragment::class)
fun bindWidgetFragment(fragment: WidgetFragment): Fragment fun bindWidgetFragment(fragment: WidgetFragment): Fragment
@Binds
@IntoMap
@FragmentKey(ContactsBookFragment::class)
fun bindPhoneBookFragment(fragment: ContactsBookFragment): Fragment
} }

View File

@ -20,6 +20,7 @@ package im.vector.riotx.core.epoxy.profiles
import android.view.View import android.view.View
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass import com.airbnb.epoxy.EpoxyModelClass
import im.vector.matrix.android.api.crypto.RoomEncryptionTrustLevel import im.vector.matrix.android.api.crypto.RoomEncryptionTrustLevel
@ -36,16 +37,21 @@ abstract class ProfileMatrixItem : VectorEpoxyModel<ProfileMatrixItem.Holder>()
@EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer @EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer
@EpoxyAttribute lateinit var matrixItem: MatrixItem @EpoxyAttribute lateinit var matrixItem: MatrixItem
@EpoxyAttribute var editable: Boolean = true
@EpoxyAttribute var userEncryptionTrustLevel: RoomEncryptionTrustLevel? = null @EpoxyAttribute var userEncryptionTrustLevel: RoomEncryptionTrustLevel? = null
@EpoxyAttribute var clickListener: View.OnClickListener? = null @EpoxyAttribute var clickListener: View.OnClickListener? = null
override fun bind(holder: Holder) { override fun bind(holder: Holder) {
super.bind(holder) super.bind(holder)
val bestName = matrixItem.getBestName() val bestName = matrixItem.getBestName()
val matrixId = matrixItem.id.takeIf { it != bestName } val matrixId = matrixItem.id
holder.view.setOnClickListener(clickListener) .takeIf { it != bestName }
// Special case for ThreePid fake matrix item
.takeIf { it != "@" }
holder.view.setOnClickListener(clickListener?.takeIf { editable })
holder.titleView.text = bestName holder.titleView.text = bestName
holder.subtitleView.setTextOrHide(matrixId) holder.subtitleView.setTextOrHide(matrixId)
holder.editableView.isVisible = editable
avatarRenderer.render(matrixItem, holder.avatarImageView) avatarRenderer.render(matrixItem, holder.avatarImageView)
holder.avatarDecorationImageView.setImageResource(userEncryptionTrustLevel.toImageRes()) holder.avatarDecorationImageView.setImageResource(userEncryptionTrustLevel.toImageRes())
} }
@ -55,5 +61,6 @@ abstract class ProfileMatrixItem : VectorEpoxyModel<ProfileMatrixItem.Holder>()
val subtitleView by bind<TextView>(R.id.matrixItemSubtitle) val subtitleView by bind<TextView>(R.id.matrixItemSubtitle)
val avatarImageView by bind<ImageView>(R.id.matrixItemAvatar) val avatarImageView by bind<ImageView>(R.id.matrixItemAvatar)
val avatarDecorationImageView by bind<ImageView>(R.id.matrixItemAvatarDecoration) val avatarDecorationImageView by bind<ImageView>(R.id.matrixItemAvatarDecoration)
val editableView by bind<View>(R.id.matrixItemEditable)
} }
} }

View File

@ -19,6 +19,9 @@ package im.vector.riotx.core.extensions
import android.os.Bundle import android.os.Bundle
import android.util.Patterns import android.util.Patterns
import androidx.fragment.app.Fragment 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" fun Boolean.toOnOff() = if (this) "ON" else "OFF"
@ -33,3 +36,15 @@ fun <T : Fragment> T.withArgs(block: Bundle.() -> Unit) = apply { arguments = Bu
* Check if a CharSequence is an email * Check if a CharSequence is an email
*/ */
fun CharSequence.isEmail() = Patterns.EMAIL_ADDRESS.matcher(this).matches() 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
}
}

View File

@ -38,13 +38,13 @@ inline fun <T, R : Comparable<R>> Iterable<T>.lastMinBy(selector: (T) -> R): T?
/** /**
* Call each for each item, and between between each items * Call each for each item, and between between each items
*/ */
inline fun <T> Collection<T>.join(each: (T) -> Unit, between: (T) -> Unit) { inline fun <T> Collection<T>.join(each: (Int, T) -> Unit, between: (Int, T) -> Unit) {
val lastIndex = size - 1 val lastIndex = size - 1
forEachIndexed { idx, t -> forEachIndexed { idx, t ->
each(t) each(idx, t)
if (idx != lastIndex) { if (idx != lastIndex) {
between(t) between(idx, t)
} }
} }
} }

View File

@ -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_PICK_ATTACHMENT = 576
const val PERMISSION_REQUEST_CODE_INCOMING_URI = 577 const val PERMISSION_REQUEST_CODE_INCOMING_URI = 577
const val PERMISSION_REQUEST_CODE_PREVIEW_FRAGMENT = 578 const val PERMISSION_REQUEST_CODE_PREVIEW_FRAGMENT = 578
const val PERMISSION_REQUEST_CODE_READ_CONTACTS = 579
/** /**
* Log the used permissions statuses. * Log the used permissions statuses.

View File

@ -17,6 +17,9 @@
package im.vector.riotx.features.command package im.vector.riotx.features.command
import im.vector.matrix.android.api.MatrixPatterns 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 import timber.log.Timber
object CommandParser { object CommandParser {
@ -139,15 +142,24 @@ object CommandParser {
if (messageParts.size >= 2) { if (messageParts.size >= 2) {
val userId = messageParts[1] val userId = messageParts[1]
if (MatrixPatterns.isUserId(userId)) { when {
ParsedCommand.Invite( MatrixPatterns.isUserId(userId) -> {
userId, ParsedCommand.Invite(
textMessage.substring(Command.INVITE.length + userId.length) userId,
.trim() textMessage.substring(Command.INVITE.length + userId.length)
.takeIf { it.isNotBlank() } .trim()
) .takeIf { it.isNotBlank() }
} else { )
ParsedCommand.ErrorSyntax(Command.INVITE) }
userId.isEmail() -> {
ParsedCommand.Invite3Pid(ThreePid.Email(userId))
}
userId.isMsisdn() -> {
ParsedCommand.Invite3Pid(ThreePid.Msisdn(userId))
}
else -> {
ParsedCommand.ErrorSyntax(Command.INVITE)
}
} }
} else { } else {
ParsedCommand.ErrorSyntax(Command.INVITE) ParsedCommand.ErrorSyntax(Command.INVITE)

View File

@ -16,6 +16,8 @@
package im.vector.riotx.features.command package im.vector.riotx.features.command
import im.vector.matrix.android.api.session.identity.ThreePid
/** /**
* Represent a parsed command * Represent a parsed command
*/ */
@ -41,6 +43,7 @@ sealed class ParsedCommand {
class UnbanUser(val userId: String, val reason: String?) : ParsedCommand() class UnbanUser(val userId: String, val reason: String?) : ParsedCommand()
class SetUserPowerLevel(val userId: String, val powerLevel: Int?) : ParsedCommand() class SetUserPowerLevel(val userId: String, val powerLevel: Int?) : ParsedCommand()
class Invite(val userId: String, val reason: String?) : 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 JoinRoom(val roomAlias: String, val reason: String?) : ParsedCommand()
class PartRoom(val roomAlias: String, val reason: String?) : ParsedCommand() class PartRoom(val roomAlias: String, val reason: String?) : ParsedCommand()
class ChangeTopic(val topic: String) : ParsedCommand() class ChangeTopic(val topic: String) : ParsedCommand()

View File

@ -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<ContactDetailItem.Holder>() {
@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<TextView>(R.id.contactDetailName)
val matrixIdView by bind<TextView>(R.id.contactDetailMatrixId)
}
}

View File

@ -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<ContactItem.Holder>() {
@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<TextView>(R.id.contactDisplayName)
val avatarImageView by bind<ImageView>(R.id.contactAvatar)
}
}

View File

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

View File

@ -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<MappedContact>,
hasSearch: Boolean,
onlyBoundContacts: Boolean) {
if (mappedContacts.isEmpty()) {
renderEmptyState(hasSearch)
} else {
renderContacts(mappedContacts, onlyBoundContacts)
}
}
private fun renderContacts(mappedContacts: List<MappedContact>, 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)
}
}

View File

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

View File

@ -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<ContactsBookViewState, ContactsBookAction, EmptyViewEvents>(initialState) {
@AssistedInject.Factory
interface Factory {
fun create(initialState: ContactsBookViewState): ContactsBookViewModel
}
companion object : MvRxViewModelFactory<ContactsBookViewModel, ContactsBookViewState> {
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<FragmentActivity>()) {
is CreateDirectRoomActivity -> viewModelContext.activity<CreateDirectRoomActivity>().contactsBookViewModelFactory.create(state)
is InviteUsersToRoomActivity -> viewModelContext.activity<InviteUsersToRoomActivity>().contactsBookViewModelFactory.create(state)
else -> error("Wrong activity or fragment")
}
}
else -> error("Wrong activity or fragment")
}
}
}
private var allContacts: List<MappedContact> = emptyList()
private var mappedContacts: List<MappedContact> = 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<MappedContact>) {
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<List<FoundThreePid>> {
override fun onFailure(failure: Throwable) {
// Ignore
Timber.w(failure, "Unable to perform the lookup")
}
override fun onSuccess(data: List<FoundThreePid>) {
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
)
}
}
}

View File

@ -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<List<MappedContact>> = 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<MappedContact> = emptyList(),
// True when the identity service has return some data
val isBoundRetrieved: Boolean = false
) : MvRxState

View File

@ -16,9 +16,9 @@
package im.vector.riotx.features.createdirect 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.core.platform.VectorViewModelAction
import im.vector.riotx.features.userdirectory.PendingInvitee
sealed class CreateDirectRoomAction : VectorViewModelAction { sealed class CreateDirectRoomAction : VectorViewModelAction {
data class CreateRoomAndInviteSelectedUsers(val selectedUsers: Set<User>) : CreateDirectRoomAction() data class CreateRoomAndInviteSelectedUsers(val invitees: Set<PendingInvitee>) : CreateDirectRoomAction()
} }

View File

@ -35,8 +35,15 @@ import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.core.error.ErrorFormatter import im.vector.riotx.core.error.ErrorFormatter
import im.vector.riotx.core.extensions.addFragment import im.vector.riotx.core.extensions.addFragment
import im.vector.riotx.core.extensions.addFragmentToBackstack 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.SimpleFragmentActivity
import im.vector.riotx.core.platform.WaitingViewData 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.KnownUsersFragment
import im.vector.riotx.features.userdirectory.KnownUsersFragmentArgs import im.vector.riotx.features.userdirectory.KnownUsersFragmentArgs
import im.vector.riotx.features.userdirectory.UserDirectoryFragment import im.vector.riotx.features.userdirectory.UserDirectoryFragment
@ -53,6 +60,7 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() {
private lateinit var sharedActionViewModel: UserDirectorySharedActionViewModel private lateinit var sharedActionViewModel: UserDirectorySharedActionViewModel
@Inject lateinit var userDirectoryViewModelFactory: UserDirectoryViewModel.Factory @Inject lateinit var userDirectoryViewModelFactory: UserDirectoryViewModel.Factory
@Inject lateinit var createDirectRoomViewModelFactory: CreateDirectRoomViewModel.Factory @Inject lateinit var createDirectRoomViewModelFactory: CreateDirectRoomViewModel.Factory
@Inject lateinit var contactsBookViewModelFactory: ContactsBookViewModel.Factory
@Inject lateinit var errorFormatter: ErrorFormatter @Inject lateinit var errorFormatter: ErrorFormatter
override fun injectWith(injector: ScreenComponent) { override fun injectWith(injector: ScreenComponent) {
@ -68,12 +76,13 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() {
.observe() .observe()
.subscribe { sharedAction -> .subscribe { sharedAction ->
when (sharedAction) { when (sharedAction) {
UserDirectorySharedAction.OpenUsersDirectory -> UserDirectorySharedAction.OpenUsersDirectory ->
addFragmentToBackstack(R.id.container, UserDirectoryFragment::class.java) addFragmentToBackstack(R.id.container, UserDirectoryFragment::class.java)
UserDirectorySharedAction.Close -> finish() UserDirectorySharedAction.Close -> finish()
UserDirectorySharedAction.GoBack -> onBackPressed() UserDirectorySharedAction.GoBack -> onBackPressed()
is UserDirectorySharedAction.OnMenuItemSelected -> onMenuItemSelected(sharedAction) is UserDirectorySharedAction.OnMenuItemSelected -> onMenuItemSelected(sharedAction)
} UserDirectorySharedAction.OpenPhoneBook -> openPhoneBook()
}.exhaustive
} }
.disposeOnDestroy() .disposeOnDestroy()
if (isFirstCreation()) { 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<String>, 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) { private fun onMenuItemSelected(action: UserDirectorySharedAction.OnMenuItemSelected) {
if (action.itemId == R.id.action_create_direct_room) { if (action.itemId == R.id.action_create_direct_room) {
viewModel.handle(CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers(action.selectedUsers)) viewModel.handle(CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers(action.invitees))
} }
} }

View File

@ -23,9 +23,10 @@ import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject import com.squareup.inject.assisted.AssistedInject
import im.vector.matrix.android.api.session.Session 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.room.model.create.CreateRoomParams
import im.vector.matrix.android.api.session.user.model.User
import im.vector.matrix.rx.rx import im.vector.matrix.rx.rx
import im.vector.riotx.core.extensions.exhaustive
import im.vector.riotx.core.platform.VectorViewModel import im.vector.riotx.core.platform.VectorViewModel
import im.vector.riotx.features.userdirectory.PendingInvitee
class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted
initialState: CreateDirectRoomViewState, initialState: CreateDirectRoomViewState,
@ -48,16 +49,22 @@ class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted
override fun handle(action: CreateDirectRoomAction) { override fun handle(action: CreateDirectRoomAction) {
when (action) { when (action) {
is CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers -> createRoomAndInviteSelectedUsers(action.selectedUsers) is CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers -> createRoomAndInviteSelectedUsers(action.invitees)
} }
} }
private fun createRoomAndInviteSelectedUsers(selectedUsers: Set<User>) { private fun createRoomAndInviteSelectedUsers(invitees: Set<PendingInvitee>) {
val roomParams = CreateRoomParams( val roomParams = CreateRoomParams()
invitedUserIds = selectedUsers.map { it.userId } .apply {
) invitees.forEach {
.setDirectMessage() when (it) {
.enableEncryptionIfInvitedUsersSupportIt() is PendingInvitee.UserPendingInvitee -> invitedUserIds.add(it.user.userId)
is PendingInvitee.ThreePidPendingInvitee -> invite3pids.add(it.threePid)
}.exhaustive
}
setDirectMessage()
enableEncryptionIfInvitedUsersSupportIt = true
}
session.rx() session.rx()
.createRoom(roomParams) .createRoom(roomParams)

View File

@ -235,11 +235,12 @@ class VerificationBottomSheetViewModel @AssistedInject constructor(
pendingRequest = Loading() pendingRequest = Loading()
) )
} }
val roomParams = CreateRoomParams( val roomParams = CreateRoomParams()
invitedUserIds = listOf(otherUserId) .apply {
) invitedUserIds.add(otherUserId)
.setDirectMessage() setDirectMessage()
.enableEncryptionIfInvitedUsersSupportIt() enableEncryptionIfInvitedUsersSupportIt = true
}
session.createRoom(roomParams, object : MatrixCallback<String> { session.createRoom(roomParams, object : MatrixCallback<String> {
override fun onSuccess(data: String) { override fun onSuccess(data: String) {

View File

@ -30,6 +30,7 @@ import com.bumptech.glide.request.target.DrawableImageViewTarget
import com.bumptech.glide.request.target.Target import com.bumptech.glide.request.target.Target
import im.vector.matrix.android.api.session.content.ContentUrlResolver import im.vector.matrix.android.api.session.content.ContentUrlResolver
import im.vector.matrix.android.api.util.MatrixItem 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.di.ActiveSessionHolder
import im.vector.riotx.core.glide.GlideApp import im.vector.riotx.core.glide.GlideApp
import im.vector.riotx.core.glide.GlideRequest import im.vector.riotx.core.glide.GlideRequest
@ -63,6 +64,23 @@ class AvatarRenderer @Inject constructor(private val activeSessionHolder: Active
DrawableImageViewTarget(imageView)) 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 @UiThread
fun render(context: Context, fun render(context: Context,
glideRequests: GlideRequests, glideRequests: GlideRequests,

View File

@ -960,7 +960,7 @@ class RoomDetailFragment @Inject constructor(
updateComposerText("") updateComposerText("")
} }
is RoomDetailViewEvents.SlashCommandResultError -> { is RoomDetailViewEvents.SlashCommandResultError -> {
displayCommandError(sendMessageResult.throwable.localizedMessage ?: getString(R.string.unexpected_error)) displayCommandError(errorFormatter.toHumanReadable(sendMessageResult.throwable))
} }
is RoomDetailViewEvents.SlashCommandNotImplemented -> { is RoomDetailViewEvents.SlashCommandNotImplemented -> {
displayCommandError(getString(R.string.not_implemented)) displayCommandError(getString(R.string.not_implemented))

View File

@ -457,6 +457,10 @@ class RoomDetailViewModel @AssistedInject constructor(
handleInviteSlashCommand(slashCommandResult) handleInviteSlashCommand(slashCommandResult)
popDraft() popDraft()
} }
is ParsedCommand.Invite3Pid -> {
handleInvite3pidSlashCommand(slashCommandResult)
popDraft()
}
is ParsedCommand.SetUserPowerLevel -> { is ParsedCommand.SetUserPowerLevel -> {
handleSetUserPowerLevel(slashCommandResult) handleSetUserPowerLevel(slashCommandResult)
popDraft() 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) { private fun handleSetUserPowerLevel(setUserPowerLevel: ParsedCommand.SetUserPowerLevel) {
val currentPowerLevelsContent = room.getStateEvent(EventType.STATE_ROOM_POWER_LEVELS) val currentPowerLevelsContent = room.getStateEvent(EventType.STATE_ROOM_POWER_LEVELS)
?.content ?.content

View File

@ -50,6 +50,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
EventType.STATE_ROOM_TOPIC, EventType.STATE_ROOM_TOPIC,
EventType.STATE_ROOM_AVATAR, EventType.STATE_ROOM_AVATAR,
EventType.STATE_ROOM_MEMBER, EventType.STATE_ROOM_MEMBER,
EventType.STATE_ROOM_THIRD_PARTY_INVITE,
EventType.STATE_ROOM_ALIASES, EventType.STATE_ROOM_ALIASES,
EventType.STATE_ROOM_CANONICAL_ALIAS, EventType.STATE_ROOM_CANONICAL_ALIAS,
EventType.STATE_ROOM_JOIN_RULES, EventType.STATE_ROOM_JOIN_RULES,
@ -96,8 +97,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
verificationConclusionItemFactory.create(event, highlight, callback) verificationConclusionItemFactory.create(event, highlight, callback)
} }
// Unhandled event types (yet) // Unhandled event types
EventType.STATE_ROOM_THIRD_PARTY_INVITE -> defaultItemFactory.create(event, highlight, callback)
else -> { else -> {
// Should only happen when shouldShowHiddenEvents() settings is ON // Should only happen when shouldShowHiddenEvents() settings is ON
Timber.v("Type ${event.root.getClearType()} not handled") Timber.v("Type ${event.root.getClearType()} not handled")

View File

@ -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.RoomJoinRulesContent
import im.vector.matrix.android.api.session.room.model.RoomMemberContent 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.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.RoomTopicContent
import im.vector.matrix.android.api.session.room.model.call.CallInviteContent import im.vector.matrix.android.api.session.room.model.call.CallInviteContent
import im.vector.matrix.android.api.session.room.model.create.RoomCreateContent 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_TOPIC -> formatRoomTopicEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName)
EventType.STATE_ROOM_AVATAR -> formatRoomAvatarEvent(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_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_ALIASES -> formatRoomAliasesEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName)
EventType.STATE_ROOM_CANONICAL_ALIAS -> formatRoomCanonicalAliasEvent(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) 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_TOPIC -> formatRoomTopicEvent(event, senderName)
EventType.STATE_ROOM_AVATAR -> formatRoomAvatarEvent(event, senderName) EventType.STATE_ROOM_AVATAR -> formatRoomAvatarEvent(event, senderName)
EventType.STATE_ROOM_MEMBER -> formatRoomMemberEvent(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.STATE_ROOM_HISTORY_VISIBILITY -> formatRoomHistoryVisibilityEvent(event, senderName)
EventType.CALL_INVITE, EventType.CALL_INVITE,
EventType.CALL_HANGUP, 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<RoomThirdPartyInviteContent>()
val prevContent = event.resolvedPrevContent()?.toModel<RoomThirdPartyInviteContent>()
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? { private fun formatCallEvent(type: String, event: Event, senderName: String?): CharSequence? {
return when (type) { return when (type) {
EventType.CALL_INVITE -> { EventType.CALL_INVITE -> {

View File

@ -16,9 +16,9 @@
package im.vector.riotx.features.invite 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.core.platform.VectorViewModelAction
import im.vector.riotx.features.userdirectory.PendingInvitee
sealed class InviteUsersToRoomAction : VectorViewModelAction { sealed class InviteUsersToRoomAction : VectorViewModelAction {
data class InviteSelectedUsers(val selectedUsers: Set<User>) : InviteUsersToRoomAction() data class InviteSelectedUsers(val invitees: Set<PendingInvitee>) : InviteUsersToRoomAction()
} }

View File

@ -30,9 +30,16 @@ import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.core.error.ErrorFormatter import im.vector.riotx.core.error.ErrorFormatter
import im.vector.riotx.core.extensions.addFragment import im.vector.riotx.core.extensions.addFragment
import im.vector.riotx.core.extensions.addFragmentToBackstack 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.SimpleFragmentActivity
import im.vector.riotx.core.platform.WaitingViewData 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.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.KnownUsersFragment
import im.vector.riotx.features.userdirectory.KnownUsersFragmentArgs import im.vector.riotx.features.userdirectory.KnownUsersFragmentArgs
import im.vector.riotx.features.userdirectory.UserDirectoryFragment import im.vector.riotx.features.userdirectory.UserDirectoryFragment
@ -53,6 +60,7 @@ class InviteUsersToRoomActivity : SimpleFragmentActivity() {
private lateinit var sharedActionViewModel: UserDirectorySharedActionViewModel private lateinit var sharedActionViewModel: UserDirectorySharedActionViewModel
@Inject lateinit var userDirectoryViewModelFactory: UserDirectoryViewModel.Factory @Inject lateinit var userDirectoryViewModelFactory: UserDirectoryViewModel.Factory
@Inject lateinit var inviteUsersToRoomViewModelFactory: InviteUsersToRoomViewModel.Factory @Inject lateinit var inviteUsersToRoomViewModelFactory: InviteUsersToRoomViewModel.Factory
@Inject lateinit var contactsBookViewModelFactory: ContactsBookViewModel.Factory
@Inject lateinit var errorFormatter: ErrorFormatter @Inject lateinit var errorFormatter: ErrorFormatter
override fun injectWith(injector: ScreenComponent) { override fun injectWith(injector: ScreenComponent) {
@ -74,7 +82,8 @@ class InviteUsersToRoomActivity : SimpleFragmentActivity() {
UserDirectorySharedAction.Close -> finish() UserDirectorySharedAction.Close -> finish()
UserDirectorySharedAction.GoBack -> onBackPressed() UserDirectorySharedAction.GoBack -> onBackPressed()
is UserDirectorySharedAction.OnMenuItemSelected -> onMenuItemSelected(sharedAction) is UserDirectorySharedAction.OnMenuItemSelected -> onMenuItemSelected(sharedAction)
} UserDirectorySharedAction.OpenPhoneBook -> openPhoneBook()
}.exhaustive
} }
.disposeOnDestroy() .disposeOnDestroy()
if (isFirstCreation()) { if (isFirstCreation()) {
@ -92,9 +101,27 @@ class InviteUsersToRoomActivity : SimpleFragmentActivity() {
viewModel.observeViewEvents { renderInviteEvents(it) } 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<String>, 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) { private fun onMenuItemSelected(action: UserDirectorySharedAction.OnMenuItemSelected) {
if (action.itemId == R.id.action_invite_users_to_room_invite) { if (action.itemId == R.id.action_invite_users_to_room_invite) {
viewModel.handle(InviteUsersToRoomAction.InviteSelectedUsers(action.selectedUsers)) viewModel.handle(InviteUsersToRoomAction.InviteSelectedUsers(action.invitees))
} }
} }

View File

@ -22,11 +22,11 @@ import com.airbnb.mvrx.ViewModelContext
import com.squareup.inject.assisted.Assisted import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject import com.squareup.inject.assisted.AssistedInject
import im.vector.matrix.android.api.session.Session 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.matrix.rx.rx
import im.vector.riotx.R import im.vector.riotx.R
import im.vector.riotx.core.platform.VectorViewModel import im.vector.riotx.core.platform.VectorViewModel
import im.vector.riotx.core.resources.StringProvider import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.features.userdirectory.PendingInvitee
import io.reactivex.Observable import io.reactivex.Observable
class InviteUsersToRoomViewModel @AssistedInject constructor(@Assisted class InviteUsersToRoomViewModel @AssistedInject constructor(@Assisted
@ -53,27 +53,30 @@ class InviteUsersToRoomViewModel @AssistedInject constructor(@Assisted
override fun handle(action: InviteUsersToRoomAction) { override fun handle(action: InviteUsersToRoomAction) {
when (action) { when (action) {
is InviteUsersToRoomAction.InviteSelectedUsers -> inviteUsersToRoom(action.selectedUsers) is InviteUsersToRoomAction.InviteSelectedUsers -> inviteUsersToRoom(action.invitees)
} }
} }
private fun inviteUsersToRoom(selectedUsers: Set<User>) { private fun inviteUsersToRoom(invitees: Set<PendingInvitee>) {
_viewEvents.post(InviteUsersToRoomViewEvents.Loading) _viewEvents.post(InviteUsersToRoomViewEvents.Loading)
Observable.fromIterable(selectedUsers).flatMapCompletable { user -> Observable.fromIterable(invitees).flatMapCompletable { user ->
room.rx().invite(user.userId, null) when (user) {
is PendingInvitee.UserPendingInvitee -> room.rx().invite(user.user.userId, null)
is PendingInvitee.ThreePidPendingInvitee -> room.rx().invite3pid(user.threePid)
}
}.subscribe( }.subscribe(
{ {
val successMessage = when (selectedUsers.size) { val successMessage = when (invitees.size) {
1 -> stringProvider.getString(R.string.invitation_sent_to_one_user, 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, 2 -> stringProvider.getString(R.string.invitations_sent_to_two_users,
selectedUsers.first().getBestName(), invitees.first().getBestName(),
selectedUsers.last().getBestName()) invitees.last().getBestName())
else -> stringProvider.getQuantityString(R.plurals.invitations_sent_to_one_and_more_users, else -> stringProvider.getQuantityString(R.plurals.invitations_sent_to_one_and_more_users,
selectedUsers.size - 1, invitees.size - 1,
selectedUsers.first().getBestName(), invitees.first().getBestName(),
selectedUsers.size - 1) invitees.size - 1)
} }
_viewEvents.post(InviteUsersToRoomViewEvents.Success(successMessage)) _viewEvents.post(InviteUsersToRoomViewEvents.Success(successMessage))
}, },

View File

@ -84,15 +84,19 @@ class CreateRoomViewModel @AssistedInject constructor(@Assisted initialState: Cr
copy(asyncCreateRoomRequest = Loading()) copy(asyncCreateRoomRequest = Loading())
} }
val createRoomParams = CreateRoomParams( val createRoomParams = CreateRoomParams()
name = state.roomName.takeIf { it.isNotBlank() }, .apply {
// Directory visibility name = state.roomName.takeIf { it.isNotBlank() }
visibility = if (state.isInRoomDirectory) RoomDirectoryVisibility.PUBLIC else RoomDirectoryVisibility.PRIVATE, // Directory visibility
// Public room visibility = if (state.isInRoomDirectory) RoomDirectoryVisibility.PUBLIC else RoomDirectoryVisibility.PRIVATE
preset = if (state.isPublic) CreateRoomPreset.PRESET_PUBLIC_CHAT else CreateRoomPreset.PRESET_PRIVATE_CHAT // Public room
) preset = if (state.isPublic) CreateRoomPreset.PRESET_PUBLIC_CHAT else CreateRoomPreset.PRESET_PRIVATE_CHAT
// Encryption
.enableEncryptionWithAlgorithm(state.isEncrypted) // Encryption
if (state.isEncrypted) {
enableEncryption()
}
}
session.createRoom(createRoomParams, object : MatrixCallback<String> { session.createRoom(createRoomParams, object : MatrixCallback<String> {
override fun onSuccess(data: String) { override fun onSuccess(data: String) {

View File

@ -18,4 +18,6 @@ package im.vector.riotx.features.roomprofile.members
import im.vector.riotx.core.platform.VectorViewModelAction import im.vector.riotx.core.platform.VectorViewModelAction
sealed class RoomMemberListAction : VectorViewModelAction sealed class RoomMemberListAction : VectorViewModelAction {
data class RevokeThreePidInvite(val stateKey: String) : RoomMemberListAction()
}

View File

@ -17,7 +17,11 @@
package im.vector.riotx.features.roomprofile.members package im.vector.riotx.features.roomprofile.members
import com.airbnb.epoxy.TypedEpoxyController 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.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.matrix.android.api.util.toMatrixItem
import im.vector.riotx.R import im.vector.riotx.R
import im.vector.riotx.core.epoxy.dividerItem import im.vector.riotx.core.epoxy.dividerItem
@ -37,6 +41,7 @@ class RoomMemberListController @Inject constructor(
interface Callback { interface Callback {
fun onRoomMemberClicked(roomMember: RoomMemberSummary) fun onRoomMemberClicked(roomMember: RoomMemberSummary)
fun onThreePidInvites(event: Event)
} }
private val dividerColor = colorProvider.getColorFromAttribute(R.attr.vctr_list_divider_color) private val dividerColor = colorProvider.getColorFromAttribute(R.attr.vctr_list_divider_color)
@ -49,15 +54,29 @@ class RoomMemberListController @Inject constructor(
override fun buildModels(data: RoomMemberListViewState?) { override fun buildModels(data: RoomMemberListViewState?) {
val roomMembersByPowerLevel = data?.roomMemberSummaries?.invoke() ?: return val roomMembersByPowerLevel = data?.roomMemberSummaries?.invoke() ?: return
val threePidInvites = data.threePidInvites().orEmpty()
var threePidInvitesDone = threePidInvites.isEmpty()
for ((powerLevelCategory, roomMemberList) in roomMembersByPowerLevel) { for ((powerLevelCategory, roomMemberList) in roomMembersByPowerLevel) {
if (roomMemberList.isEmpty()) { if (roomMemberList.isEmpty()) {
continue 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( buildProfileSection(
stringProvider.getString(powerLevelCategory.titleRes) stringProvider.getString(powerLevelCategory.titleRes)
) )
roomMemberList.join( roomMemberList.join(
each = { roomMember -> each = { _, roomMember ->
profileMatrixItem { profileMatrixItem {
id(roomMember.userId) id(roomMember.userId)
matrixItem(roomMember.toMatrixItem()) matrixItem(roomMember.toMatrixItem())
@ -68,13 +87,62 @@ class RoomMemberListController @Inject constructor(
} }
} }
}, },
between = { roomMemberBefore -> between = { _, roomMemberBefore ->
dividerItem { dividerItem {
id("divider_${roomMemberBefore.userId}") id("divider_${roomMemberBefore.userId}")
color(dividerColor) 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<RoomThirdPartyInviteContent>() != null }
?.join(
each = { idx, event ->
event.content.toModel<RoomThirdPartyInviteContent>()
?.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)
}
} }

View File

@ -20,10 +20,14 @@ import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.appcompat.app.AlertDialog
import com.airbnb.mvrx.args import com.airbnb.mvrx.args
import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState 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.RoomMemberSummary
import im.vector.matrix.android.api.session.room.model.RoomThirdPartyInviteContent
import im.vector.matrix.android.api.util.toMatrixItem import im.vector.matrix.android.api.util.toMatrixItem
import im.vector.riotx.R import im.vector.riotx.R
import im.vector.riotx.core.extensions.cleanup import im.vector.riotx.core.extensions.cleanup
@ -88,6 +92,22 @@ class RoomMemberListFragment @Inject constructor(
navigator.openRoomMemberProfile(roomMember.userId, roomId = roomProfileArgs.roomId, context = requireActivity()) 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<RoomThirdPartyInviteContent>() ?: 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) { private fun renderRoomSummary(state: RoomMemberListViewState) {
state.roomSummary()?.let { state.roomSummary()?.let {
roomSettingsToolbarTitleView.text = it.displayName roomSettingsToolbarTitleView.text = it.displayName

View File

@ -16,11 +16,13 @@
package im.vector.riotx.features.roomprofile.members package im.vector.riotx.features.roomprofile.members
import androidx.lifecycle.viewModelScope
import com.airbnb.mvrx.FragmentViewModelContext import com.airbnb.mvrx.FragmentViewModelContext
import com.airbnb.mvrx.MvRxViewModelFactory import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext import com.airbnb.mvrx.ViewModelContext
import com.squareup.inject.assisted.Assisted import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject 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.crypto.RoomEncryptionTrustLevel
import im.vector.matrix.android.api.extensions.orFalse import im.vector.matrix.android.api.extensions.orFalse
import im.vector.matrix.android.api.query.QueryStringValue 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.mapOptional
import im.vector.matrix.rx.rx import im.vector.matrix.rx.rx
import im.vector.matrix.rx.unwrap 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.EmptyViewEvents
import im.vector.riotx.core.platform.VectorViewModel import im.vector.riotx.core.platform.VectorViewModel
import im.vector.riotx.features.powerlevel.PowerLevelsObservableFactory import im.vector.riotx.features.powerlevel.PowerLevelsObservableFactory
import io.reactivex.Observable import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.functions.BiFunction import io.reactivex.functions.BiFunction
import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
class RoomMemberListViewModel @AssistedInject constructor(@Assisted initialState: RoomMemberListViewState, class RoomMemberListViewModel @AssistedInject constructor(@Assisted initialState: RoomMemberListViewState,
@ -68,6 +72,7 @@ class RoomMemberListViewModel @AssistedInject constructor(@Assisted initialState
init { init {
observeRoomMemberSummaries() observeRoomMemberSummaries()
observeThirdPartyInvites()
observeRoomSummary() observeRoomSummary()
observePowerLevel() observePowerLevel()
} }
@ -124,7 +129,12 @@ class RoomMemberListViewModel @AssistedInject constructor(@Assisted initialState
PowerLevelsObservableFactory(room).createObservable() PowerLevelsObservableFactory(room).createObservable()
.subscribe { .subscribe {
val permissions = ActionPermissions( 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 { setState {
copy(actionsPermissions = permissions) 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<RoomMemberSummary>): RoomMemberSummaries { private fun buildRoomMemberSummaries(powerLevelsContent: PowerLevelsContent, roomMembers: List<RoomMemberSummary>): RoomMemberSummaries {
val admins = ArrayList<RoomMemberSummary>() val admins = ArrayList<RoomMemberSummary>()
val moderators = ArrayList<RoomMemberSummary>() val moderators = ArrayList<RoomMemberSummary>()
@ -169,5 +186,19 @@ class RoomMemberListViewModel @AssistedInject constructor(@Assisted initialState
} }
override fun handle(action: RoomMemberListAction) { 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()
)
}
} }
} }

View File

@ -21,6 +21,7 @@ import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MvRxState import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.Uninitialized
import im.vector.matrix.android.api.crypto.RoomEncryptionTrustLevel 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.RoomMemberSummary
import im.vector.matrix.android.api.session.room.model.RoomSummary import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.riotx.R import im.vector.riotx.R
@ -30,6 +31,7 @@ data class RoomMemberListViewState(
val roomId: String, val roomId: String,
val roomSummary: Async<RoomSummary> = Uninitialized, val roomSummary: Async<RoomSummary> = Uninitialized,
val roomMemberSummaries: Async<RoomMemberSummaries> = Uninitialized, val roomMemberSummaries: Async<RoomMemberSummaries> = Uninitialized,
val threePidInvites: Async<List<Event>> = Uninitialized,
val trustLevelMap: Async<Map<String, RoomEncryptionTrustLevel?>> = Uninitialized, val trustLevelMap: Async<Map<String, RoomEncryptionTrustLevel?>> = Uninitialized,
val actionsPermissions: ActionPermissions = ActionPermissions() val actionsPermissions: ActionPermissions = ActionPermissions()
) : MvRxState { ) : MvRxState {
@ -38,7 +40,8 @@ data class RoomMemberListViewState(
} }
data class ActionPermissions( data class ActionPermissions(
val canInvite: Boolean = false val canInvite: Boolean = false,
val canRevokeThreePidInvite: Boolean = false
) )
typealias RoomMemberSummaries = List<Pair<RoomMemberListCategories, List<RoomMemberSummary>>> typealias RoomMemberSummaries = List<Pair<RoomMemberListCategories, List<RoomMemberSummary>>>

View File

@ -60,7 +60,7 @@ class DirectoryUsersController @Inject constructor(private val session: Session,
is Loading -> renderLoading() is Loading -> renderLoading()
is Success -> renderSuccess( is Success -> renderSuccess(
computeUsersList(asyncUsers(), currentState.directorySearchTerm), computeUsersList(asyncUsers(), currentState.directorySearchTerm),
currentState.selectedUsers.map { it.userId }, currentState.getSelectedMatrixId(),
hasSearch hasSearch
) )
is Fail -> renderFailure(asyncUsers.error) is Fail -> renderFailure(asyncUsers.error)

View File

@ -51,7 +51,7 @@ class KnownUsersController @Inject constructor(private val session: Session,
fun setData(state: UserDirectoryViewState) { fun setData(state: UserDirectoryViewState) {
this.isFiltering = !state.filterKnownUsersValue.isEmpty() this.isFiltering = !state.filterKnownUsersValue.isEmpty()
val newSelection = state.selectedUsers.map { it.userId } val newSelection = state.getSelectedMatrixId()
this.users = state.knownUsers this.users = state.knownUsers
if (newSelection != selectedUsers) { if (newSelection != selectedUsers) {
this.selectedUsers = newSelection this.selectedUsers = newSelection

View File

@ -63,8 +63,9 @@ class KnownUsersFragment @Inject constructor(
setupRecyclerView() setupRecyclerView()
setupFilterView() setupFilterView()
setupAddByMatrixIdView() setupAddByMatrixIdView()
setupAddFromPhoneBookView()
setupCloseView() setupCloseView()
viewModel.selectSubscribe(this, UserDirectoryViewState::selectedUsers) { viewModel.selectSubscribe(this, UserDirectoryViewState::pendingInvitees) {
renderSelectedUsers(it) renderSelectedUsers(it)
} }
} }
@ -77,7 +78,7 @@ class KnownUsersFragment @Inject constructor(
override fun onPrepareOptionsMenu(menu: Menu) { override fun onPrepareOptionsMenu(menu: Menu) {
withState(viewModel) { withState(viewModel) {
val showMenuItem = it.selectedUsers.isNotEmpty() val showMenuItem = it.pendingInvitees.isNotEmpty()
menu.forEach { menuItem -> menu.forEach { menuItem ->
menuItem.isVisible = showMenuItem menuItem.isVisible = showMenuItem
} }
@ -86,7 +87,7 @@ class KnownUsersFragment @Inject constructor(
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean = withState(viewModel) { 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 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() { private fun setupRecyclerView() {
knownUsersController.callback = this knownUsersController.callback = this
// Don't activate animation as we might have way to much item animation when filtering // 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) knownUsersController.setData(it)
} }
private fun renderSelectedUsers(selectedUsers: Set<User>) { private fun renderSelectedUsers(invitees: Set<PendingInvitee>) {
invalidateOptionsMenu() invalidateOptionsMenu()
val currentNumberOfChips = chipGroup.childCount val currentNumberOfChips = chipGroup.childCount
val newNumberOfChips = selectedUsers.size val newNumberOfChips = invitees.size
chipGroup.removeAllViews() chipGroup.removeAllViews()
selectedUsers.forEach { addChipToGroup(it) } invitees.forEach { addChipToGroup(it) }
// Scroll to the bottom when adding chips. When removing chips, do not scroll // Scroll to the bottom when adding chips. When removing chips, do not scroll
if (newNumberOfChips >= currentNumberOfChips) { 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()) val chip = Chip(requireContext())
chip.setChipBackgroundColorResource(android.R.color.transparent) chip.setChipBackgroundColorResource(android.R.color.transparent)
chip.chipStrokeWidth = dimensionConverter.dpToPx(1).toFloat() chip.chipStrokeWidth = dimensionConverter.dpToPx(1).toFloat()
chip.text = user.getBestName() chip.text = pendingInvitee.getBestName()
chip.isClickable = true chip.isClickable = true
chip.isCheckable = false chip.isCheckable = false
chip.isCloseIconVisible = true chip.isCloseIconVisible = true
chipGroup.addView(chip) chipGroup.addView(chip)
chip.setOnCloseIconClickListener { chip.setOnCloseIconClickListener {
viewModel.handle(UserDirectoryAction.RemoveSelectedUser(user)) viewModel.handle(UserDirectoryAction.RemovePendingInvitee(pendingInvitee))
} }
} }
override fun onItemClick(user: User) { override fun onItemClick(user: User) {
view?.hideKeyboard() view?.hideKeyboard()
viewModel.handle(UserDirectoryAction.SelectUser(user)) viewModel.handle(UserDirectoryAction.SelectPendingInvitee(PendingInvitee.UserPendingInvitee(user)))
} }
} }

View File

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

View File

@ -16,13 +16,12 @@
package im.vector.riotx.features.userdirectory package im.vector.riotx.features.userdirectory
import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotx.core.platform.VectorViewModelAction import im.vector.riotx.core.platform.VectorViewModelAction
sealed class UserDirectoryAction : VectorViewModelAction { sealed class UserDirectoryAction : VectorViewModelAction {
data class FilterKnownUsers(val value: String) : UserDirectoryAction() data class FilterKnownUsers(val value: String) : UserDirectoryAction()
data class SearchDirectoryUsers(val value: String) : UserDirectoryAction() data class SearchDirectoryUsers(val value: String) : UserDirectoryAction()
object ClearFilterKnownUsers : UserDirectoryAction() object ClearFilterKnownUsers : UserDirectoryAction()
data class SelectUser(val user: User) : UserDirectoryAction() data class SelectPendingInvitee(val pendingInvitee: PendingInvitee) : UserDirectoryAction()
data class RemoveSelectedUser(val user: User) : UserDirectoryAction() data class RemovePendingInvitee(val pendingInvitee: PendingInvitee) : UserDirectoryAction()
} }

View File

@ -84,7 +84,7 @@ class UserDirectoryFragment @Inject constructor(
override fun onItemClick(user: User) { override fun onItemClick(user: User) {
view?.hideKeyboard() view?.hideKeyboard()
viewModel.handle(UserDirectoryAction.SelectUser(user)) viewModel.handle(UserDirectoryAction.SelectPendingInvitee(PendingInvitee.UserPendingInvitee(user)))
sharedActionViewModel.post(UserDirectorySharedAction.GoBack) sharedActionViewModel.post(UserDirectorySharedAction.GoBack)
} }

View File

@ -16,12 +16,12 @@
package im.vector.riotx.features.userdirectory package im.vector.riotx.features.userdirectory
import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotx.core.platform.VectorSharedAction import im.vector.riotx.core.platform.VectorSharedAction
sealed class UserDirectorySharedAction : VectorSharedAction { sealed class UserDirectorySharedAction : VectorSharedAction {
object OpenUsersDirectory : UserDirectorySharedAction() object OpenUsersDirectory : UserDirectorySharedAction()
object OpenPhoneBook : UserDirectorySharedAction()
object Close : UserDirectorySharedAction() object Close : UserDirectorySharedAction()
object GoBack : UserDirectorySharedAction() object GoBack : UserDirectorySharedAction()
data class OnMenuItemSelected(val itemId: Int, val selectedUsers: Set<User>) : UserDirectorySharedAction() data class OnMenuItemSelected(val itemId: Int, val invitees: Set<PendingInvitee>) : UserDirectorySharedAction()
} }

View File

@ -28,6 +28,7 @@ import com.squareup.inject.assisted.AssistedInject
import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.util.toMatrixItem import im.vector.matrix.android.api.util.toMatrixItem
import im.vector.matrix.rx.rx 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.extensions.toggle
import im.vector.riotx.core.platform.VectorViewModel import im.vector.riotx.core.platform.VectorViewModel
import im.vector.riotx.features.createdirect.CreateDirectRoomActivity 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 FragmentViewModelContext -> (viewModelContext.fragment() as KnownUsersFragment).userDirectoryViewModelFactory.create(state)
is ActivityViewModelContext -> { is ActivityViewModelContext -> {
when (viewModelContext.activity<FragmentActivity>()) { when (viewModelContext.activity<FragmentActivity>()) {
is CreateDirectRoomActivity -> viewModelContext.activity<CreateDirectRoomActivity>().userDirectoryViewModelFactory.create(state) is CreateDirectRoomActivity -> viewModelContext.activity<CreateDirectRoomActivity>().userDirectoryViewModelFactory.create(state)
is InviteUsersToRoomActivity -> viewModelContext.activity<InviteUsersToRoomActivity>().userDirectoryViewModelFactory.create(state) is InviteUsersToRoomActivity -> viewModelContext.activity<InviteUsersToRoomActivity>().userDirectoryViewModelFactory.create(state)
else -> error("Wrong activity or fragment") 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.FilterKnownUsers -> knownUsersFilter.accept(Option.just(action.value))
is UserDirectoryAction.ClearFilterKnownUsers -> knownUsersFilter.accept(Option.empty()) is UserDirectoryAction.ClearFilterKnownUsers -> knownUsersFilter.accept(Option.empty())
is UserDirectoryAction.SearchDirectoryUsers -> directoryUsersSearch.accept(action.value) is UserDirectoryAction.SearchDirectoryUsers -> directoryUsersSearch.accept(action.value)
is UserDirectoryAction.SelectUser -> handleSelectUser(action) is UserDirectoryAction.SelectPendingInvitee -> handleSelectUser(action)
is UserDirectoryAction.RemoveSelectedUser -> handleRemoveSelectedUser(action) is UserDirectoryAction.RemovePendingInvitee -> handleRemoveSelectedUser(action)
} }.exhaustive
} }
private fun handleRemoveSelectedUser(action: UserDirectoryAction.RemoveSelectedUser) = withState { state -> private fun handleRemoveSelectedUser(action: UserDirectoryAction.RemovePendingInvitee) = withState { state ->
val selectedUsers = state.selectedUsers.minus(action.user) val selectedUsers = state.pendingInvitees.minus(action.pendingInvitee)
setState { copy(selectedUsers = selectedUsers) } setState { copy(pendingInvitees = selectedUsers) }
} }
private fun handleSelectUser(action: UserDirectoryAction.SelectUser) = withState { state -> private fun handleSelectUser(action: UserDirectoryAction.SelectPendingInvitee) = withState { state ->
// Reset the filter asap // Reset the filter asap
directoryUsersSearch.accept("") directoryUsersSearch.accept("")
val selectedUsers = state.selectedUsers.toggle(action.user) val selectedUsers = state.pendingInvitees.toggle(action.pendingInvitee)
setState { copy(selectedUsers = selectedUsers) } setState { copy(pendingInvitees = selectedUsers) }
} }
private fun observeDirectoryUsers() = withState { state -> private fun observeDirectoryUsers() = withState { state ->

View File

@ -27,11 +27,21 @@ data class UserDirectoryViewState(
val excludedUserIds: Set<String>? = null, val excludedUserIds: Set<String>? = null,
val knownUsers: Async<PagedList<User>> = Uninitialized, val knownUsers: Async<PagedList<User>> = Uninitialized,
val directoryUsers: Async<List<User>> = Uninitialized, val directoryUsers: Async<List<User>> = Uninitialized,
val selectedUsers: Set<User> = emptySet(), val pendingInvitees: Set<PendingInvitee> = emptySet(),
val createAndInviteState: Async<String> = Uninitialized, val createAndInviteState: Async<String> = Uninitialized,
val directorySearchTerm: String = "", val directorySearchTerm: String = "",
val filterKnownUsersValue: Option<String> = Option.empty() val filterKnownUsersValue: Option<String> = Option.empty()
) : MvRxState { ) : MvRxState {
constructor(args: KnownUsersFragmentArgs) : this(excludedUserIds = args.excludedUserIds) constructor(args: KnownUsersFragmentArgs) : this(excludedUserIds = args.excludedUserIds)
fun getSelectedMatrixId(): List<String> {
return pendingInvitees
.mapNotNull {
when (it) {
is PendingInvitee.UserPendingInvitee -> it.user.userId
is PendingInvitee.ThreePidPendingInvitee -> null
}
}
}
} }

View File

@ -0,0 +1,122 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.appcompat.widget.Toolbar
android:id="@+id/phoneBookToolbar"
style="@style/VectorToolbarStyle"
android:layout_width="0dp"
android:layout_height="?actionBarSize"
android:elevation="4dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/phoneBookClose"
android:layout_width="@dimen/layout_touch_size"
android:layout_height="@dimen/layout_touch_size"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
android:scaleType="center"
android:src="@drawable/ic_x_18dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/phoneBookTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/contacts_book_title"
android:textColor="?riotx_text_primary"
android:textSize="18sp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toEndOf="@+id/phoneBookClose"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.appcompat.widget.Toolbar>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/phoneBookFilterContainer"
style="@style/VectorTextInputLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/layout_horizontal_margin"
android:layout_marginTop="16dp"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/phoneBookToolbar">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/phoneBookFilter"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/search" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/phoneBookOnlyBoundContacts"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/layout_horizontal_margin"
android:layout_marginTop="4dp"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:text="@string/matrix_only_filter"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/phoneBookFilterContainer"
tools:visibility="visible" />
<View
android:id="@+id/phoneBookFilterDivider"
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_marginTop="4dp"
android:background="?attr/vctr_list_divider_color"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/phoneBookOnlyBoundContacts" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/phoneBookRecyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
android:fastScrollEnabled="true"
android:overScrollMode="always"
android:scrollbars="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/phoneBookFilterDivider"
tools:listitem="@layout/item_contact_main" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -123,6 +123,23 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/knownUsersFilterDivider" /> app:layout_constraintTop_toBottomOf="@id/knownUsersFilterDivider" />
<com.google.android.material.button.MaterialButton
android:id="@+id/addFromPhoneBook"
style="@style/VectorButtonStyleText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:minHeight="@dimen/layout_touch_size"
android:text="@string/search_in_my_contacts"
android:visibility="visible"
app:icon="@drawable/ic_plus_circle"
app:iconPadding="13dp"
app:iconTint="@color/riotx_accent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/addByMatrixId" />
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView" android:id="@+id/recyclerView"
android:layout_width="0dp" android:layout_width="0dp"
@ -134,7 +151,7 @@
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/addByMatrixId" app:layout_constraintTop_toBottomOf="@+id/addFromPhoneBook"
tools:listitem="@layout/item_known_user" /> tools:listitem="@layout/item_known_user" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?riotx_background"
android:foreground="?attr/selectableItemBackground"
android:minHeight="60dp"
android:padding="8dp">
<TextView
android:id="@+id/contactDetailName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginStart="60dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?riotx_text_primary"
android:textSize="15sp"
app:layout_constraintBottom_toTopOf="@id/contactDetailMatrixId"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:text="@tools:sample/full_names" />
<TextView
android:id="@+id/contactDetailMatrixId"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?riotx_text_secondary"
android:textSize="15sp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/contactDetailName"
app:layout_constraintTop_toBottomOf="@+id/contactDetailName"
tools:text="@sample/matrix.json/data/mxid"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?riotx_background"
android:foreground="?attr/selectableItemBackground"
android:paddingStart="8dp"
android:paddingTop="12dp"
android:paddingEnd="8dp">
<ImageView
android:id="@+id/contactAvatar"
android:layout_width="48dp"
android:layout_height="48dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@tools:sample/avatars" />
<TextView
android:id="@+id/contactDisplayName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginStart="12dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?riotx_text_primary"
android:textSize="15sp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/contactAvatar"
app:layout_constraintTop_toTopOf="parent"
tools:text="@tools:sample/full_names" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -2541,4 +2541,15 @@ Not all features in Riot are implemented in RiotX yet. Main missing (and coming
<string name="notice_crypto_unable_to_decrypt_merged">Waiting for encryption history</string> <string name="notice_crypto_unable_to_decrypt_merged">Waiting for encryption history</string>
<string name="save_recovery_key_chooser_hint">Save recovery key in</string> <string name="save_recovery_key_chooser_hint">Save recovery key in</string>
<string name="add_from_phone_book">Add from my phone book</string>
<string name="empty_phone_book">Your phone book is empty</string>
<string name="phone_book_title">Phone book</string>
<string name="search_in_my_contacts">Search in my contacts</string>
<string name="loading_contact_book">Retrieving your contacts…</string>
<string name="empty_contact_book">Your contact book is empty</string>
<string name="contacts_book_title">Contacts book</string>
<string name="three_pid_revoke_invite_dialog_title">Revoke invite</string>
<string name="three_pid_revoke_invite_dialog_content">Revoke invite to %1$s?</string>
</resources> </resources>