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)
- Set up SSSS from security settings (#1567)
- New lab setting to add 'unread notifications' tab to main screen
- Render third party invite event (#548)
- Display three pid invites in the room members list (#548)
Bugfix 🐛:
- Integration Manager: Wrong URL to review terms if URL in config contains path (#1606)
@ -27,7 +29,7 @@ Translations 🗣:
-
SDK API changes ⚠️:
-
- CreateRoomParams has been updated
Build 🧱:
- Upgrade some dependencies

View File

@ -19,6 +19,7 @@ package im.vector.matrix.rx
import android.net.Uri
import im.vector.matrix.android.api.query.QueryStringValue
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.identity.ThreePid
import im.vector.matrix.android.api.session.room.Room
import im.vector.matrix.android.api.session.room.members.RoomMemberQueryParams
import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary
@ -71,6 +72,13 @@ class RxRoom(private val room: Room) {
}
}
fun liveStateEvents(eventTypes: Set<String>): Observable<List<Event>> {
return room.getStateEventsLive(eventTypes).asObservable()
.startWithCallable {
room.getStateEvents(eventTypes)
}
}
fun liveReadMarker(): Observable<Optional<String>> {
return room.getReadMarkerLive().asObservable()
}
@ -104,6 +112,10 @@ class RxRoom(private val room: Room) {
room.invite(userId, reason, it)
}
fun invite3pid(threePid: ThreePid): Completable = completableBuilder<Unit> {
room.invite3pid(threePid, it)
}
fun updateTopic(topic: String): Completable = completableBuilder<Unit> {
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 roomId = mTestHelper.doSync<String> {
aliceSession.createRoom(CreateRoomParams(name = "MyRoom"), it)
aliceSession.createRoom(CreateRoomParams().apply { name = "MyRoom" }, it)
}
if (encryptedRoom) {
@ -175,7 +175,7 @@ class CryptoTestHelper(private val mTestHelper: CommonTestHelper) {
}
mTestHelper.doSync<Unit> {
samSession.joinRoom(room.roomId, null, it)
samSession.joinRoom(room.roomId, null, emptyList(), it)
}
return samSession
@ -286,9 +286,11 @@ class CryptoTestHelper(private val mTestHelper: CommonTestHelper) {
fun createDM(alice: Session, bob: Session): String {
val roomId = mTestHelper.doSync<String> {
alice.createRoom(
CreateRoomParams(invitedUserIds = listOf(bob.myUserId))
.setDirectMessage()
.enableEncryptionIfInvitedUsersSupportIt(),
CreateRoomParams().apply {
invitedUserIds.add(bob.myUserId)
setDirectMessage()
enableEncryptionIfInvitedUsersSupportIt = true
},
it
)
}

View File

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

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 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 im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.identity.ThreePid
import im.vector.matrix.android.api.session.room.model.RoomMemberSummary
import im.vector.matrix.android.api.util.Cancelable
@ -63,6 +64,12 @@ interface MembershipService {
reason: String? = null,
callback: MatrixCallback<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
*/

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");
* you may not use this file except in compliance with the License.
@ -16,253 +16,102 @@
package im.vector.matrix.android.api.session.room.model.create
import android.util.Patterns
import androidx.annotation.CheckResult
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import im.vector.matrix.android.api.MatrixPatterns.isUserId
import im.vector.matrix.android.api.auth.data.HomeServerConnectionConfig
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.toContent
import im.vector.matrix.android.api.session.identity.ThreePid
import im.vector.matrix.android.api.session.room.model.PowerLevelsContent
import im.vector.matrix.android.api.session.room.model.RoomDirectoryVisibility
import im.vector.matrix.android.api.session.room.model.RoomHistoryVisibility
import im.vector.matrix.android.internal.auth.data.ThreePidMedium
import im.vector.matrix.android.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM
import timber.log.Timber
/**
* Parameter to create a room, with facilities functions to configure it
*/
@JsonClass(generateAdapter = true)
data class CreateRoomParams(
/**
* A public visibility indicates that the room will be shown in the published room list.
* A private visibility will hide the room from the published room list.
* Rooms default to private visibility if this key is not included.
* NB: This should not be confused with join_rules which also uses the word public. One of: ["public", "private"]
*/
@Json(name = "visibility")
val visibility: RoomDirectoryVisibility? = null,
/**
* The desired room alias local part. If this is included, a room alias will be created and mapped to the newly created room.
* The alias will belong on the same homeserver which created the room.
* For example, if this was set to "foo" and sent to the homeserver "example.com" the complete room alias would be #foo:example.com.
*/
@Json(name = "room_alias_name")
val roomAliasName: String? = null,
/**
* If this is included, an m.room.name event will be sent into the room to indicate the name of the room.
* See Room Events for more information on m.room.name.
*/
@Json(name = "name")
val name: String? = null,
/**
* If this is included, an m.room.topic event will be sent into the room to indicate the topic for the room.
* See Room Events for more information on m.room.topic.
*/
@Json(name = "topic")
val topic: String? = null,
/**
* A list of user IDs to invite to the room.
* This will tell the server to invite everyone in the list to the newly created room.
*/
@Json(name = "invite")
val invitedUserIds: List<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
// TODO Give a way to include other initial states
class CreateRoomParams {
/**
* A public visibility indicates that the room will be shown in the published room list.
* A private visibility will hide the room from the published room list.
* Rooms default to private visibility if this key is not included.
* NB: This should not be confused with join_rules which also uses the word public. One of: ["public", "private"]
*/
var visibility: RoomDirectoryVisibility? = null
/**
* After calling this method, when the room will be created, if cross-signing is enabled and we can get keys for every invited users,
* The desired room alias local part. If this is included, a room alias will be created and mapped to the newly created room.
* The alias will belong on the same homeserver which created the room.
* For example, if this was set to "foo" and sent to the homeserver "example.com" the complete room alias would be #foo:example.com.
*/
var roomAliasName: String? = null
/**
* If this is not null, an m.room.name event will be sent into the room to indicate the name of the room.
* See Room Events for more information on m.room.name.
*/
var name: String? = null
/**
* If this is not null, an m.room.topic event will be sent into the room to indicate the topic for the room.
* See Room Events for more information on m.room.topic.
*/
var topic: String? = null
/**
* A list of user IDs to invite to the room.
* This will tell the server to invite everyone in the list to the newly created room.
*/
val invitedUserIds = mutableListOf<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
* @param value true to activate this behavior.
* @return this, to allow chaining methods
*/
fun enableEncryptionIfInvitedUsersSupportIt(value: Boolean = true): CreateRoomParams {
enableEncryptionIfInvitedUsersSupportIt = value
return this
}
var enableEncryptionIfInvitedUsersSupportIt: Boolean = false
/**
* Add the crypto algorithm to the room creation parameters.
*
* @param enable true to enable encryption.
* @param algorithm the algorithm, default to [MXCRYPTO_ALGORITHM_MEGOLM], which is actually the only supported algorithm for the moment
* @return a modified copy of the CreateRoomParams object, or this if there is no modification
* Convenience parameter for setting various default state events based on a preset. Must be either:
* private_chat => join_rules is set to invite. history_visibility is set to shared.
* trusted_private_chat => join_rules is set to invite. history_visibility is set to shared. All invitees are given the same power level as the
* room creator.
* public_chat: => join_rules is set to public. history_visibility is set to shared.
*/
@CheckResult
fun enableEncryptionWithAlgorithm(enable: Boolean = true,
algorithm: String = MXCRYPTO_ALGORITHM_MEGOLM): CreateRoomParams {
// Remove the existing value if any.
val newInitialStates = initialStates
?.filter { it.type != EventType.STATE_ROOM_ENCRYPTION }
return if (algorithm == MXCRYPTO_ALGORITHM_MEGOLM) {
if (enable) {
val contentMap = mapOf("algorithm" to algorithm)
val algoEvent = Event(
type = EventType.STATE_ROOM_ENCRYPTION,
stateKey = "",
content = contentMap.toContent()
)
copy(
initialStates = newInitialStates.orEmpty() + algoEvent
)
} else {
return copy(
initialStates = newInitialStates
)
}
} else {
Timber.e("Unsupported algorithm: $algorithm")
this
}
}
var preset: CreateRoomPreset? = null
/**
* Force the history visibility in the room creation parameters.
*
* @param historyVisibility the expected history visibility, set null to remove any existing value.
* @return a modified copy of the CreateRoomParams object
* This flag makes the server set the is_direct flag on the m.room.member events sent to the users in invite and invite_3pid.
* See Direct Messaging for more information.
*/
@CheckResult
fun setHistoryVisibility(historyVisibility: RoomHistoryVisibility?): CreateRoomParams {
// Remove the existing value if any.
val newInitialStates = initialStates
?.filter { it.type != EventType.STATE_ROOM_HISTORY_VISIBILITY }
var isDirect: Boolean? = null
if (historyVisibility != null) {
val contentMap = mapOf("history_visibility" to historyVisibility)
/**
* Extra keys to be added to the content of the m.room.create.
* The server will clobber the following keys: creator.
* Future versions of the specification may allow the server to clobber other keys.
*/
var creationContent: Any? = null
val historyVisibilityEvent = Event(
type = EventType.STATE_ROOM_HISTORY_VISIBILITY,
stateKey = "",
content = contentMap.toContent())
return copy(
initialStates = newInitialStates.orEmpty() + historyVisibilityEvent
)
} else {
return copy(
initialStates = newInitialStates
)
}
}
/**
* The power level content to override in the default power level event
*/
var powerLevelContentOverride: PowerLevelsContent? = null
/**
* Mark as a direct message room.
* @return a modified copy of the CreateRoomParams object
*/
@CheckResult
fun setDirectMessage(): CreateRoomParams {
return copy(
preset = CreateRoomPreset.PRESET_TRUSTED_PRIVATE_CHAT,
isDirect = true
)
fun setDirectMessage() {
preset = CreateRoomPreset.PRESET_TRUSTED_PRIVATE_CHAT
isDirect = true
}
/**
* Tells if the created room can be a direct chat one.
*
* @return true if it is a direct chat
* Supported value: MXCRYPTO_ALGORITHM_MEGOLM
*/
fun isDirect(): Boolean {
return preset == CreateRoomPreset.PRESET_TRUSTED_PRIVATE_CHAT
&& isDirect == true
}
var algorithm: String? = null
private set
/**
* @return the first invited user id
*/
fun getFirstInvitedUserId(): String? {
return invitedUserIds?.firstOrNull() ?: invite3pids?.firstOrNull()?.address
}
var historyVisibility: RoomHistoryVisibility? = null
/**
* Add some ids to the room creation
* ids might be a matrix id or an email address.
*
* @param ids the participant ids to add.
* @return a modified copy of the CreateRoomParams object
*/
@CheckResult
fun addParticipantIds(hsConfig: HomeServerConnectionConfig,
userId: String,
ids: List<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
fun enableEncryption() {
algorithm = MXCRYPTO_ALGORITHM_MEGOLM
}
}

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
internal class DefaultIdentityService @Inject constructor(
private val identityStore: IdentityStore,
private val ensureIdentityTokenTask: EnsureIdentityTokenTask,
private val getOpenIdTokenTask: GetOpenIdTokenTask,
private val identityBulkLookupTask: IdentityBulkLookupTask,
private val identityRegisterTask: IdentityRegisterTask,
@ -278,7 +279,7 @@ internal class DefaultIdentityService @Inject constructor(
}
private suspend fun lookUpInternal(canRetry: Boolean, threePids: List<ThreePid>): List<FoundThreePid> {
ensureToken()
ensureIdentityTokenTask.execute(Unit)
return try {
identityBulkLookupTask.execute(IdentityBulkLookupTask.Params(threePids))
@ -295,17 +296,6 @@ internal class DefaultIdentityService @Inject constructor(
}
}
private suspend fun ensureToken() {
val identityData = identityStore.getIdentityData() ?: throw IdentityServiceError.NoIdentityServerConfigured
val url = identityData.identityServerUrl ?: throw IdentityServiceError.NoIdentityServerConfigured
if (identityData.token == null) {
// Try to get a token
val token = getNewIdentityServerToken(url)
identityStore.setToken(token)
}
}
private suspend fun getNewIdentityServerToken(url: String): String {
val api = retrofitFactory.create(unauthenticatedOkHttpClient, url).create(IdentityAuthAPI::class.java)

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
abstract fun bindIdentityStore(store: RealmIdentityStore): IdentityStore
@Binds
abstract fun bindEnsureIdentityTokenTask(task: DefaultEnsureIdentityTokenTask): EnsureIdentityTokenTask
@Binds
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.Event
import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams
import im.vector.matrix.android.api.session.room.model.create.CreateRoomResponse
import im.vector.matrix.android.api.session.room.model.create.JoinRoomResponse
import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoomsParams
import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoomsResponse
import im.vector.matrix.android.api.session.room.model.thirdparty.ThirdPartyProtocol
@ -28,9 +25,13 @@ import im.vector.matrix.android.api.util.JsonDict
import im.vector.matrix.android.internal.network.NetworkConstants
import im.vector.matrix.android.internal.session.room.alias.AddRoomAliasBody
import im.vector.matrix.android.internal.session.room.alias.RoomAliasDescription
import im.vector.matrix.android.internal.session.room.create.CreateRoomBody
import im.vector.matrix.android.internal.session.room.create.CreateRoomResponse
import im.vector.matrix.android.internal.session.room.create.JoinRoomResponse
import im.vector.matrix.android.internal.session.room.membership.RoomMembersResponse
import im.vector.matrix.android.internal.session.room.membership.admin.UserIdAndReason
import im.vector.matrix.android.internal.session.room.membership.joining.InviteBody
import im.vector.matrix.android.internal.session.room.membership.threepid.ThreePidInviteBody
import im.vector.matrix.android.internal.session.room.relation.RelationsResponse
import im.vector.matrix.android.internal.session.room.reporting.ReportContentBody
import im.vector.matrix.android.internal.session.room.send.SendResponse
@ -79,7 +80,7 @@ internal interface RoomAPI {
*/
@Headers("CONNECT_TIMEOUT:60000", "READ_TIMEOUT:60000", "WRITE_TIMEOUT:60000")
@POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "createRoom")
fun createRoom(@Body param: CreateRoomParams): Call<CreateRoomResponse>
fun createRoom(@Body param: CreateRoomBody): Call<CreateRoomResponse>
/**
* Get a list of messages starting from a reference.
@ -170,6 +171,14 @@ internal interface RoomAPI {
@POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "rooms/{roomId}/invite")
fun invite(@Path("roomId") roomId: String, @Body body: InviteBody): Call<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
*

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

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");
* you may not use this file except in compliance with the License.
@ -14,7 +14,7 @@
* limitations under the License.
*/
package im.vector.matrix.android.api.session.room.model.create
package im.vector.matrix.android.internal.session.room.create
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

View File

@ -17,11 +17,9 @@
package im.vector.matrix.android.internal.session.room.create
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.session.crypto.crosssigning.CrossSigningService
import im.vector.matrix.android.api.session.room.failure.CreateRoomFailure
import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams
import im.vector.matrix.android.api.session.room.model.create.CreateRoomResponse
import im.vector.matrix.android.internal.crypto.DeviceListManager
import im.vector.matrix.android.api.session.room.model.create.CreateRoomPreset
import im.vector.matrix.android.internal.database.awaitNotEmptyResult
import im.vector.matrix.android.internal.database.model.RoomEntity
import im.vector.matrix.android.internal.database.model.RoomEntityFields
@ -51,20 +49,15 @@ internal class DefaultCreateRoomTask @Inject constructor(
private val readMarkersTask: SetReadMarkersTask,
@SessionDatabase
private val realmConfiguration: RealmConfiguration,
private val crossSigningService: CrossSigningService,
private val deviceListManager: DeviceListManager,
private val createRoomBodyBuilder: CreateRoomBodyBuilder,
private val eventBus: EventBus
) : CreateRoomTask {
override suspend fun execute(params: CreateRoomParams): String {
val createRoomParams = if (canEnableEncryption(params)) {
params.enableEncryptionWithAlgorithm()
} else {
params
}
val createRoomBody = createRoomBodyBuilder.build(params)
val createRoomResponse = executeRequest<CreateRoomResponse>(eventBus) {
apiCall = roomAPI.createRoom(createRoomParams)
apiCall = roomAPI.createRoom(createRoomBody)
}
val roomId = createRoomResponse.roomId
// Wait for room to come back from the sync (but it can maybe be in the DB if the sync response is received before)
@ -76,35 +69,13 @@ internal class DefaultCreateRoomTask @Inject constructor(
} catch (exception: TimeoutCancellationException) {
throw CreateRoomFailure.CreatedWithTimeout
}
if (createRoomParams.isDirect()) {
handleDirectChatCreation(createRoomParams, roomId)
if (params.isDirect()) {
handleDirectChatCreation(params, roomId)
}
setReadMarkers(roomId)
return roomId
}
private suspend fun canEnableEncryption(params: CreateRoomParams): Boolean {
return params.enableEncryptionIfInvitedUsersSupportIt
&& crossSigningService.isCrossSigningVerified()
&& params.invite3pids.isNullOrEmpty()
&& params.invitedUserIds?.isNotEmpty() == true
&& params.invitedUserIds.let { userIds ->
val keys = deviceListManager.downloadKeys(userIds, forceDownload = false)
userIds.all { userId ->
keys.map[userId].let { deviceMap ->
if (deviceMap.isNullOrEmpty()) {
// A user has no device, so do not enable encryption
false
} else {
// Check that every user's device have at least one key
deviceMap.values.all { !it.keys.isNullOrEmpty() }
}
}
}
}
}
private suspend fun handleDirectChatCreation(params: CreateRoomParams, roomId: String) {
val otherUserId = params.getFirstInvitedUserId()
?: throw IllegalStateException("You can't create a direct room without an invitedUser")
@ -123,4 +94,21 @@ internal class DefaultCreateRoomTask @Inject constructor(
val setReadMarkerParams = SetReadMarkersTask.Params(roomId, forceReadReceipt = true, forceReadMarker = true)
return readMarkersTask.execute(setReadMarkerParams)
}
/**
* Tells if the created room can be a direct chat one.
*
* @return true if it is a direct chat
*/
private fun CreateRoomParams.isDirect(): Boolean {
return preset == CreateRoomPreset.PRESET_TRUSTED_PRIVATE_CHAT
&& isDirect == true
}
/**
* @return the first invited user id
*/
private fun CreateRoomParams.getFirstInvitedUserId(): String? {
return invitedUserIds.firstOrNull() ?: invite3pids.firstOrNull()?.value
}
}

View File

@ -21,6 +21,7 @@ import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.identity.ThreePid
import im.vector.matrix.android.api.session.room.members.MembershipService
import im.vector.matrix.android.api.session.room.members.RoomMemberQueryParams
import im.vector.matrix.android.api.session.room.model.Membership
@ -36,6 +37,7 @@ import im.vector.matrix.android.internal.session.room.membership.admin.Membershi
import im.vector.matrix.android.internal.session.room.membership.joining.InviteTask
import im.vector.matrix.android.internal.session.room.membership.joining.JoinRoomTask
import im.vector.matrix.android.internal.session.room.membership.leaving.LeaveRoomTask
import im.vector.matrix.android.internal.session.room.membership.threepid.InviteThreePidTask
import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith
import im.vector.matrix.android.internal.util.fetchCopied
@ -48,6 +50,7 @@ internal class DefaultMembershipService @AssistedInject constructor(
private val taskExecutor: TaskExecutor,
private val loadRoomMembersTask: LoadRoomMembersTask,
private val inviteTask: InviteTask,
private val inviteThreePidTask: InviteThreePidTask,
private val joinTask: JoinRoomTask,
private val leaveRoomTask: LeaveRoomTask,
private val membershipAdminTask: MembershipAdminTask,
@ -152,6 +155,15 @@ internal class DefaultMembershipService @AssistedInject constructor(
.executeBy(taskExecutor)
}
override fun invite3pid(threePid: ThreePid, callback: MatrixCallback<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 {
val params = JoinRoomTask.Params(roomId, reason, viaServers)
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.members.ChangeMembershipState
import im.vector.matrix.android.api.session.room.model.create.JoinRoomResponse
import im.vector.matrix.android.internal.database.awaitNotEmptyResult
import im.vector.matrix.android.internal.database.model.RoomEntity
import im.vector.matrix.android.internal.database.model.RoomEntityFields
import im.vector.matrix.android.internal.di.SessionDatabase
import im.vector.matrix.android.internal.network.executeRequest
import im.vector.matrix.android.internal.session.room.RoomAPI
import im.vector.matrix.android.internal.session.room.create.JoinRoomResponse
import im.vector.matrix.android.internal.session.room.membership.RoomChangeMembershipStateDataSource
import im.vector.matrix.android.internal.session.room.read.SetReadMarkersTask
import im.vector.matrix.android.internal.task.Task

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

View File

@ -20,6 +20,7 @@ package im.vector.riotx.core.epoxy.profiles
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.matrix.android.api.crypto.RoomEncryptionTrustLevel
@ -36,16 +37,21 @@ abstract class ProfileMatrixItem : VectorEpoxyModel<ProfileMatrixItem.Holder>()
@EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer
@EpoxyAttribute lateinit var matrixItem: MatrixItem
@EpoxyAttribute var editable: Boolean = true
@EpoxyAttribute var userEncryptionTrustLevel: RoomEncryptionTrustLevel? = null
@EpoxyAttribute var clickListener: View.OnClickListener? = null
override fun bind(holder: Holder) {
super.bind(holder)
val bestName = matrixItem.getBestName()
val matrixId = matrixItem.id.takeIf { it != bestName }
holder.view.setOnClickListener(clickListener)
val matrixId = matrixItem.id
.takeIf { it != bestName }
// Special case for ThreePid fake matrix item
.takeIf { it != "@" }
holder.view.setOnClickListener(clickListener?.takeIf { editable })
holder.titleView.text = bestName
holder.subtitleView.setTextOrHide(matrixId)
holder.editableView.isVisible = editable
avatarRenderer.render(matrixItem, holder.avatarImageView)
holder.avatarDecorationImageView.setImageResource(userEncryptionTrustLevel.toImageRes())
}
@ -55,5 +61,6 @@ abstract class ProfileMatrixItem : VectorEpoxyModel<ProfileMatrixItem.Holder>()
val subtitleView by bind<TextView>(R.id.matrixItemSubtitle)
val avatarImageView by bind<ImageView>(R.id.matrixItemAvatar)
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.util.Patterns
import androidx.fragment.app.Fragment
import com.google.i18n.phonenumbers.NumberParseException
import com.google.i18n.phonenumbers.PhoneNumberUtil
import im.vector.matrix.android.api.extensions.ensurePrefix
fun Boolean.toOnOff() = if (this) "ON" else "OFF"
@ -33,3 +36,15 @@ fun <T : Fragment> T.withArgs(block: Bundle.() -> Unit) = apply { arguments = Bu
* Check if a CharSequence is an email
*/
fun CharSequence.isEmail() = Patterns.EMAIL_ADDRESS.matcher(this).matches()
/**
* Check if a CharSequence is a phone number
*/
fun CharSequence.isMsisdn(): Boolean {
return try {
PhoneNumberUtil.getInstance().parse(ensurePrefix("+"), null)
true
} catch (e: NumberParseException) {
false
}
}

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
*/
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
forEachIndexed { idx, t ->
each(t)
each(idx, t)
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_INCOMING_URI = 577
const val PERMISSION_REQUEST_CODE_PREVIEW_FRAGMENT = 578
const val PERMISSION_REQUEST_CODE_READ_CONTACTS = 579
/**
* Log the used permissions statuses.

View File

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

View File

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

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
import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotx.core.platform.VectorViewModelAction
import im.vector.riotx.features.userdirectory.PendingInvitee
sealed class CreateDirectRoomAction : VectorViewModelAction {
data class CreateRoomAndInviteSelectedUsers(val selectedUsers: Set<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.extensions.addFragment
import im.vector.riotx.core.extensions.addFragmentToBackstack
import im.vector.riotx.core.extensions.exhaustive
import im.vector.riotx.core.platform.SimpleFragmentActivity
import im.vector.riotx.core.platform.WaitingViewData
import im.vector.riotx.core.utils.PERMISSIONS_FOR_MEMBERS_SEARCH
import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_READ_CONTACTS
import im.vector.riotx.core.utils.allGranted
import im.vector.riotx.core.utils.checkPermissions
import im.vector.riotx.features.contactsbook.ContactsBookFragment
import im.vector.riotx.features.contactsbook.ContactsBookViewModel
import im.vector.riotx.features.userdirectory.KnownUsersFragment
import im.vector.riotx.features.userdirectory.KnownUsersFragmentArgs
import im.vector.riotx.features.userdirectory.UserDirectoryFragment
@ -53,6 +60,7 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() {
private lateinit var sharedActionViewModel: UserDirectorySharedActionViewModel
@Inject lateinit var userDirectoryViewModelFactory: UserDirectoryViewModel.Factory
@Inject lateinit var createDirectRoomViewModelFactory: CreateDirectRoomViewModel.Factory
@Inject lateinit var contactsBookViewModelFactory: ContactsBookViewModel.Factory
@Inject lateinit var errorFormatter: ErrorFormatter
override fun injectWith(injector: ScreenComponent) {
@ -68,12 +76,13 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() {
.observe()
.subscribe { sharedAction ->
when (sharedAction) {
UserDirectorySharedAction.OpenUsersDirectory ->
UserDirectorySharedAction.OpenUsersDirectory ->
addFragmentToBackstack(R.id.container, UserDirectoryFragment::class.java)
UserDirectorySharedAction.Close -> finish()
UserDirectorySharedAction.GoBack -> onBackPressed()
UserDirectorySharedAction.Close -> finish()
UserDirectorySharedAction.GoBack -> onBackPressed()
is UserDirectorySharedAction.OnMenuItemSelected -> onMenuItemSelected(sharedAction)
}
UserDirectorySharedAction.OpenPhoneBook -> openPhoneBook()
}.exhaustive
}
.disposeOnDestroy()
if (isFirstCreation()) {
@ -91,9 +100,27 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() {
}
}
private fun openPhoneBook() {
// Check permission first
if (checkPermissions(PERMISSIONS_FOR_MEMBERS_SEARCH,
this,
PERMISSION_REQUEST_CODE_READ_CONTACTS,
0)) {
addFragmentToBackstack(R.id.container, ContactsBookFragment::class.java)
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<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) {
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 im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams
import im.vector.matrix.android.api.session.user.model.User
import im.vector.matrix.rx.rx
import im.vector.riotx.core.extensions.exhaustive
import im.vector.riotx.core.platform.VectorViewModel
import im.vector.riotx.features.userdirectory.PendingInvitee
class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted
initialState: CreateDirectRoomViewState,
@ -48,16 +49,22 @@ class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted
override fun handle(action: CreateDirectRoomAction) {
when (action) {
is CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers -> createRoomAndInviteSelectedUsers(action.selectedUsers)
is CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers -> createRoomAndInviteSelectedUsers(action.invitees)
}
}
private fun createRoomAndInviteSelectedUsers(selectedUsers: Set<User>) {
val roomParams = CreateRoomParams(
invitedUserIds = selectedUsers.map { it.userId }
)
.setDirectMessage()
.enableEncryptionIfInvitedUsersSupportIt()
private fun createRoomAndInviteSelectedUsers(invitees: Set<PendingInvitee>) {
val roomParams = CreateRoomParams()
.apply {
invitees.forEach {
when (it) {
is PendingInvitee.UserPendingInvitee -> invitedUserIds.add(it.user.userId)
is PendingInvitee.ThreePidPendingInvitee -> invite3pids.add(it.threePid)
}.exhaustive
}
setDirectMessage()
enableEncryptionIfInvitedUsersSupportIt = true
}
session.rx()
.createRoom(roomParams)

View File

@ -235,11 +235,12 @@ class VerificationBottomSheetViewModel @AssistedInject constructor(
pendingRequest = Loading()
)
}
val roomParams = CreateRoomParams(
invitedUserIds = listOf(otherUserId)
)
.setDirectMessage()
.enableEncryptionIfInvitedUsersSupportIt()
val roomParams = CreateRoomParams()
.apply {
invitedUserIds.add(otherUserId)
setDirectMessage()
enableEncryptionIfInvitedUsersSupportIt = true
}
session.createRoom(roomParams, object : MatrixCallback<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 im.vector.matrix.android.api.session.content.ContentUrlResolver
import im.vector.matrix.android.api.util.MatrixItem
import im.vector.riotx.core.contacts.MappedContact
import im.vector.riotx.core.di.ActiveSessionHolder
import im.vector.riotx.core.glide.GlideApp
import im.vector.riotx.core.glide.GlideRequest
@ -63,6 +64,23 @@ class AvatarRenderer @Inject constructor(private val activeSessionHolder: Active
DrawableImageViewTarget(imageView))
}
@UiThread
fun render(mappedContact: MappedContact, imageView: ImageView) {
// Create a Fake MatrixItem, for the placeholder
val matrixItem = MatrixItem.UserItem(
// Need an id starting with @
id = "@${mappedContact.displayName}",
displayName = mappedContact.displayName
)
val placeholder = getPlaceholderDrawable(imageView.context, matrixItem)
GlideApp.with(imageView)
.load(mappedContact.photoURI)
.apply(RequestOptions.circleCropTransform())
.placeholder(placeholder)
.into(imageView)
}
@UiThread
fun render(context: Context,
glideRequests: GlideRequests,

View File

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

View File

@ -457,6 +457,10 @@ class RoomDetailViewModel @AssistedInject constructor(
handleInviteSlashCommand(slashCommandResult)
popDraft()
}
is ParsedCommand.Invite3Pid -> {
handleInvite3pidSlashCommand(slashCommandResult)
popDraft()
}
is ParsedCommand.SetUserPowerLevel -> {
handleSetUserPowerLevel(slashCommandResult)
popDraft()
@ -678,6 +682,12 @@ class RoomDetailViewModel @AssistedInject constructor(
}
}
private fun handleInvite3pidSlashCommand(invite: ParsedCommand.Invite3Pid) {
launchSlashCommandFlow {
room.invite3pid(invite.threePid, it)
}
}
private fun handleSetUserPowerLevel(setUserPowerLevel: ParsedCommand.SetUserPowerLevel) {
val currentPowerLevelsContent = room.getStateEvent(EventType.STATE_ROOM_POWER_LEVELS)
?.content

View File

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

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.RoomMemberContent
import im.vector.matrix.android.api.session.room.model.RoomNameContent
import im.vector.matrix.android.api.session.room.model.RoomThirdPartyInviteContent
import im.vector.matrix.android.api.session.room.model.RoomTopicContent
import im.vector.matrix.android.api.session.room.model.call.CallInviteContent
import im.vector.matrix.android.api.session.room.model.create.RoomCreateContent
@ -63,6 +64,7 @@ class NoticeEventFormatter @Inject constructor(private val activeSessionDataSour
EventType.STATE_ROOM_TOPIC -> formatRoomTopicEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName)
EventType.STATE_ROOM_AVATAR -> formatRoomAvatarEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName)
EventType.STATE_ROOM_MEMBER -> formatRoomMemberEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName)
EventType.STATE_ROOM_THIRD_PARTY_INVITE -> formatRoomThirdPartyInvite(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName)
EventType.STATE_ROOM_ALIASES -> formatRoomAliasesEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName)
EventType.STATE_ROOM_CANONICAL_ALIAS -> formatRoomCanonicalAliasEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName)
EventType.STATE_ROOM_HISTORY_VISIBILITY -> formatRoomHistoryVisibilityEvent(timelineEvent.root, timelineEvent.senderInfo.disambiguatedDisplayName)
@ -156,6 +158,7 @@ class NoticeEventFormatter @Inject constructor(private val activeSessionDataSour
EventType.STATE_ROOM_TOPIC -> formatRoomTopicEvent(event, senderName)
EventType.STATE_ROOM_AVATAR -> formatRoomAvatarEvent(event, senderName)
EventType.STATE_ROOM_MEMBER -> formatRoomMemberEvent(event, senderName)
EventType.STATE_ROOM_THIRD_PARTY_INVITE -> formatRoomThirdPartyInvite(event, senderName)
EventType.STATE_ROOM_HISTORY_VISIBILITY -> formatRoomHistoryVisibilityEvent(event, senderName)
EventType.CALL_INVITE,
EventType.CALL_HANGUP,
@ -254,6 +257,31 @@ class NoticeEventFormatter @Inject constructor(private val activeSessionDataSour
}
}
private fun formatRoomThirdPartyInvite(event: Event, senderName: String?): CharSequence? {
val content = event.getClearContent().toModel<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? {
return when (type) {
EventType.CALL_INVITE -> {

View File

@ -16,9 +16,9 @@
package im.vector.riotx.features.invite
import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotx.core.platform.VectorViewModelAction
import im.vector.riotx.features.userdirectory.PendingInvitee
sealed class InviteUsersToRoomAction : VectorViewModelAction {
data class InviteSelectedUsers(val selectedUsers: Set<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.extensions.addFragment
import im.vector.riotx.core.extensions.addFragmentToBackstack
import im.vector.riotx.core.extensions.exhaustive
import im.vector.riotx.core.platform.SimpleFragmentActivity
import im.vector.riotx.core.platform.WaitingViewData
import im.vector.riotx.core.utils.PERMISSIONS_FOR_MEMBERS_SEARCH
import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_READ_CONTACTS
import im.vector.riotx.core.utils.allGranted
import im.vector.riotx.core.utils.checkPermissions
import im.vector.riotx.core.utils.toast
import im.vector.riotx.features.contactsbook.ContactsBookFragment
import im.vector.riotx.features.contactsbook.ContactsBookViewModel
import im.vector.riotx.features.userdirectory.KnownUsersFragment
import im.vector.riotx.features.userdirectory.KnownUsersFragmentArgs
import im.vector.riotx.features.userdirectory.UserDirectoryFragment
@ -53,6 +60,7 @@ class InviteUsersToRoomActivity : SimpleFragmentActivity() {
private lateinit var sharedActionViewModel: UserDirectorySharedActionViewModel
@Inject lateinit var userDirectoryViewModelFactory: UserDirectoryViewModel.Factory
@Inject lateinit var inviteUsersToRoomViewModelFactory: InviteUsersToRoomViewModel.Factory
@Inject lateinit var contactsBookViewModelFactory: ContactsBookViewModel.Factory
@Inject lateinit var errorFormatter: ErrorFormatter
override fun injectWith(injector: ScreenComponent) {
@ -74,7 +82,8 @@ class InviteUsersToRoomActivity : SimpleFragmentActivity() {
UserDirectorySharedAction.Close -> finish()
UserDirectorySharedAction.GoBack -> onBackPressed()
is UserDirectorySharedAction.OnMenuItemSelected -> onMenuItemSelected(sharedAction)
}
UserDirectorySharedAction.OpenPhoneBook -> openPhoneBook()
}.exhaustive
}
.disposeOnDestroy()
if (isFirstCreation()) {
@ -92,9 +101,27 @@ class InviteUsersToRoomActivity : SimpleFragmentActivity() {
viewModel.observeViewEvents { renderInviteEvents(it) }
}
private fun openPhoneBook() {
// Check permission first
if (checkPermissions(PERMISSIONS_FOR_MEMBERS_SEARCH,
this,
PERMISSION_REQUEST_CODE_READ_CONTACTS,
0)) {
addFragmentToBackstack(R.id.container, ContactsBookFragment::class.java)
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<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) {
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.AssistedInject
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.user.model.User
import im.vector.matrix.rx.rx
import im.vector.riotx.R
import im.vector.riotx.core.platform.VectorViewModel
import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.features.userdirectory.PendingInvitee
import io.reactivex.Observable
class InviteUsersToRoomViewModel @AssistedInject constructor(@Assisted
@ -53,27 +53,30 @@ class InviteUsersToRoomViewModel @AssistedInject constructor(@Assisted
override fun handle(action: InviteUsersToRoomAction) {
when (action) {
is InviteUsersToRoomAction.InviteSelectedUsers -> inviteUsersToRoom(action.selectedUsers)
is InviteUsersToRoomAction.InviteSelectedUsers -> inviteUsersToRoom(action.invitees)
}
}
private fun inviteUsersToRoom(selectedUsers: Set<User>) {
private fun inviteUsersToRoom(invitees: Set<PendingInvitee>) {
_viewEvents.post(InviteUsersToRoomViewEvents.Loading)
Observable.fromIterable(selectedUsers).flatMapCompletable { user ->
room.rx().invite(user.userId, null)
Observable.fromIterable(invitees).flatMapCompletable { user ->
when (user) {
is PendingInvitee.UserPendingInvitee -> room.rx().invite(user.user.userId, null)
is PendingInvitee.ThreePidPendingInvitee -> room.rx().invite3pid(user.threePid)
}
}.subscribe(
{
val successMessage = when (selectedUsers.size) {
val successMessage = when (invitees.size) {
1 -> stringProvider.getString(R.string.invitation_sent_to_one_user,
selectedUsers.first().getBestName())
invitees.first().getBestName())
2 -> stringProvider.getString(R.string.invitations_sent_to_two_users,
selectedUsers.first().getBestName(),
selectedUsers.last().getBestName())
invitees.first().getBestName(),
invitees.last().getBestName())
else -> stringProvider.getQuantityString(R.plurals.invitations_sent_to_one_and_more_users,
selectedUsers.size - 1,
selectedUsers.first().getBestName(),
selectedUsers.size - 1)
invitees.size - 1,
invitees.first().getBestName(),
invitees.size - 1)
}
_viewEvents.post(InviteUsersToRoomViewEvents.Success(successMessage))
},

View File

@ -84,15 +84,19 @@ class CreateRoomViewModel @AssistedInject constructor(@Assisted initialState: Cr
copy(asyncCreateRoomRequest = Loading())
}
val createRoomParams = CreateRoomParams(
name = state.roomName.takeIf { it.isNotBlank() },
// Directory visibility
visibility = if (state.isInRoomDirectory) RoomDirectoryVisibility.PUBLIC else RoomDirectoryVisibility.PRIVATE,
// Public room
preset = if (state.isPublic) CreateRoomPreset.PRESET_PUBLIC_CHAT else CreateRoomPreset.PRESET_PRIVATE_CHAT
)
// Encryption
.enableEncryptionWithAlgorithm(state.isEncrypted)
val createRoomParams = CreateRoomParams()
.apply {
name = state.roomName.takeIf { it.isNotBlank() }
// Directory visibility
visibility = if (state.isInRoomDirectory) RoomDirectoryVisibility.PUBLIC else RoomDirectoryVisibility.PRIVATE
// Public room
preset = if (state.isPublic) CreateRoomPreset.PRESET_PUBLIC_CHAT else CreateRoomPreset.PRESET_PRIVATE_CHAT
// Encryption
if (state.isEncrypted) {
enableEncryption()
}
}
session.createRoom(createRoomParams, object : MatrixCallback<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
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
import com.airbnb.epoxy.TypedEpoxyController
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.RoomMemberSummary
import im.vector.matrix.android.api.session.room.model.RoomThirdPartyInviteContent
import im.vector.matrix.android.api.util.MatrixItem
import im.vector.matrix.android.api.util.toMatrixItem
import im.vector.riotx.R
import im.vector.riotx.core.epoxy.dividerItem
@ -37,6 +41,7 @@ class RoomMemberListController @Inject constructor(
interface Callback {
fun onRoomMemberClicked(roomMember: RoomMemberSummary)
fun onThreePidInvites(event: Event)
}
private val dividerColor = colorProvider.getColorFromAttribute(R.attr.vctr_list_divider_color)
@ -49,15 +54,29 @@ class RoomMemberListController @Inject constructor(
override fun buildModels(data: RoomMemberListViewState?) {
val roomMembersByPowerLevel = data?.roomMemberSummaries?.invoke() ?: return
val threePidInvites = data.threePidInvites().orEmpty()
var threePidInvitesDone = threePidInvites.isEmpty()
for ((powerLevelCategory, roomMemberList) in roomMembersByPowerLevel) {
if (roomMemberList.isEmpty()) {
continue
}
if (powerLevelCategory == RoomMemberListCategories.USER && !threePidInvitesDone) {
// If there is not regular invite, display threepid invite before the regular user
buildProfileSection(
stringProvider.getString(RoomMemberListCategories.INVITE.titleRes)
)
buildThreePidInvites(data)
threePidInvitesDone = true
}
buildProfileSection(
stringProvider.getString(powerLevelCategory.titleRes)
)
roomMemberList.join(
each = { roomMember ->
each = { _, roomMember ->
profileMatrixItem {
id(roomMember.userId)
matrixItem(roomMember.toMatrixItem())
@ -68,13 +87,62 @@ class RoomMemberListController @Inject constructor(
}
}
},
between = { roomMemberBefore ->
between = { _, roomMemberBefore ->
dividerItem {
id("divider_${roomMemberBefore.userId}")
color(dividerColor)
}
}
)
if (powerLevelCategory == RoomMemberListCategories.INVITE) {
// Display the threepid invite after the regular invite
dividerItem {
id("divider_threepidinvites")
color(dividerColor)
}
buildThreePidInvites(data)
threePidInvitesDone = true
}
}
if (!threePidInvitesDone) {
// If there is not regular invite and no regular user, finally display threepid invite here
buildProfileSection(
stringProvider.getString(RoomMemberListCategories.INVITE.titleRes)
)
buildThreePidInvites(data)
}
}
private fun buildThreePidInvites(data: RoomMemberListViewState) {
data.threePidInvites()
?.filter { it.content.toModel<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.MenuItem
import android.view.View
import androidx.appcompat.app.AlertDialog
import com.airbnb.mvrx.args
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.RoomMemberSummary
import im.vector.matrix.android.api.session.room.model.RoomThirdPartyInviteContent
import im.vector.matrix.android.api.util.toMatrixItem
import im.vector.riotx.R
import im.vector.riotx.core.extensions.cleanup
@ -88,6 +92,22 @@ class RoomMemberListFragment @Inject constructor(
navigator.openRoomMemberProfile(roomMember.userId, roomId = roomProfileArgs.roomId, context = requireActivity())
}
override fun onThreePidInvites(event: Event) {
// Display a dialog to revoke invite if power level is high enough
val content = event.content.toModel<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) {
state.roomSummary()?.let {
roomSettingsToolbarTitleView.text = it.displayName

View File

@ -16,11 +16,13 @@
package im.vector.riotx.features.roomprofile.members
import androidx.lifecycle.viewModelScope
import com.airbnb.mvrx.FragmentViewModelContext
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject
import im.vector.matrix.android.api.NoOpMatrixCallback
import im.vector.matrix.android.api.crypto.RoomEncryptionTrustLevel
import im.vector.matrix.android.api.extensions.orFalse
import im.vector.matrix.android.api.query.QueryStringValue
@ -37,12 +39,14 @@ import im.vector.matrix.rx.asObservable
import im.vector.matrix.rx.mapOptional
import im.vector.matrix.rx.rx
import im.vector.matrix.rx.unwrap
import im.vector.riotx.core.extensions.exhaustive
import im.vector.riotx.core.platform.EmptyViewEvents
import im.vector.riotx.core.platform.VectorViewModel
import im.vector.riotx.features.powerlevel.PowerLevelsObservableFactory
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.functions.BiFunction
import kotlinx.coroutines.launch
import timber.log.Timber
class RoomMemberListViewModel @AssistedInject constructor(@Assisted initialState: RoomMemberListViewState,
@ -68,6 +72,7 @@ class RoomMemberListViewModel @AssistedInject constructor(@Assisted initialState
init {
observeRoomMemberSummaries()
observeThirdPartyInvites()
observeRoomSummary()
observePowerLevel()
}
@ -124,7 +129,12 @@ class RoomMemberListViewModel @AssistedInject constructor(@Assisted initialState
PowerLevelsObservableFactory(room).createObservable()
.subscribe {
val permissions = ActionPermissions(
canInvite = PowerLevelsHelper(it).isUserAbleToInvite(session.myUserId)
canInvite = PowerLevelsHelper(it).isUserAbleToInvite(session.myUserId),
canRevokeThreePidInvite = PowerLevelsHelper(it).isUserAllowedToSend(
userId = session.myUserId,
isState = true,
eventType = EventType.STATE_ROOM_THIRD_PARTY_INVITE
)
)
setState {
copy(actionsPermissions = permissions)
@ -140,6 +150,13 @@ class RoomMemberListViewModel @AssistedInject constructor(@Assisted initialState
}
}
private fun observeThirdPartyInvites() {
room.rx().liveStateEvents(setOf(EventType.STATE_ROOM_THIRD_PARTY_INVITE))
.execute { async ->
copy(threePidInvites = async)
}
}
private fun buildRoomMemberSummaries(powerLevelsContent: PowerLevelsContent, roomMembers: List<RoomMemberSummary>): RoomMemberSummaries {
val admins = ArrayList<RoomMemberSummary>()
val moderators = ArrayList<RoomMemberSummary>()
@ -169,5 +186,19 @@ class RoomMemberListViewModel @AssistedInject constructor(@Assisted initialState
}
override fun handle(action: RoomMemberListAction) {
when (action) {
is RoomMemberListAction.RevokeThreePidInvite -> handleRevokeThreePidInvite(action)
}.exhaustive
}
private fun handleRevokeThreePidInvite(action: RoomMemberListAction.RevokeThreePidInvite) {
viewModelScope.launch {
room.sendStateEvent(
eventType = EventType.STATE_ROOM_THIRD_PARTY_INVITE,
stateKey = action.stateKey,
body = emptyMap(),
callback = NoOpMatrixCallback()
)
}
}
}

View File

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

View File

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

View File

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

View File

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

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
import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotx.core.platform.VectorViewModelAction
sealed class UserDirectoryAction : VectorViewModelAction {
data class FilterKnownUsers(val value: String) : UserDirectoryAction()
data class SearchDirectoryUsers(val value: String) : UserDirectoryAction()
object ClearFilterKnownUsers : UserDirectoryAction()
data class SelectUser(val user: User) : UserDirectoryAction()
data class RemoveSelectedUser(val user: User) : UserDirectoryAction()
data class SelectPendingInvitee(val pendingInvitee: PendingInvitee) : UserDirectoryAction()
data class RemovePendingInvitee(val pendingInvitee: PendingInvitee) : UserDirectoryAction()
}

View File

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

View File

@ -16,12 +16,12 @@
package im.vector.riotx.features.userdirectory
import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotx.core.platform.VectorSharedAction
sealed class UserDirectorySharedAction : VectorSharedAction {
object OpenUsersDirectory : UserDirectorySharedAction()
object OpenPhoneBook : UserDirectorySharedAction()
object Close : UserDirectorySharedAction()
object GoBack : UserDirectorySharedAction()
data class OnMenuItemSelected(val itemId: Int, val selectedUsers: Set<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.util.toMatrixItem
import im.vector.matrix.rx.rx
import im.vector.riotx.core.extensions.exhaustive
import im.vector.riotx.core.extensions.toggle
import im.vector.riotx.core.platform.VectorViewModel
import im.vector.riotx.features.createdirect.CreateDirectRoomActivity
@ -59,9 +60,9 @@ class UserDirectoryViewModel @AssistedInject constructor(@Assisted
is FragmentViewModelContext -> (viewModelContext.fragment() as KnownUsersFragment).userDirectoryViewModelFactory.create(state)
is ActivityViewModelContext -> {
when (viewModelContext.activity<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)
else -> error("Wrong activity or fragment")
else -> error("Wrong activity or fragment")
}
}
else -> error("Wrong activity or fragment")
@ -79,21 +80,21 @@ class UserDirectoryViewModel @AssistedInject constructor(@Assisted
is UserDirectoryAction.FilterKnownUsers -> knownUsersFilter.accept(Option.just(action.value))
is UserDirectoryAction.ClearFilterKnownUsers -> knownUsersFilter.accept(Option.empty())
is UserDirectoryAction.SearchDirectoryUsers -> directoryUsersSearch.accept(action.value)
is UserDirectoryAction.SelectUser -> handleSelectUser(action)
is UserDirectoryAction.RemoveSelectedUser -> handleRemoveSelectedUser(action)
}
is UserDirectoryAction.SelectPendingInvitee -> handleSelectUser(action)
is UserDirectoryAction.RemovePendingInvitee -> handleRemoveSelectedUser(action)
}.exhaustive
}
private fun handleRemoveSelectedUser(action: UserDirectoryAction.RemoveSelectedUser) = withState { state ->
val selectedUsers = state.selectedUsers.minus(action.user)
setState { copy(selectedUsers = selectedUsers) }
private fun handleRemoveSelectedUser(action: UserDirectoryAction.RemovePendingInvitee) = withState { state ->
val selectedUsers = state.pendingInvitees.minus(action.pendingInvitee)
setState { copy(pendingInvitees = selectedUsers) }
}
private fun handleSelectUser(action: UserDirectoryAction.SelectUser) = withState { state ->
private fun handleSelectUser(action: UserDirectoryAction.SelectPendingInvitee) = withState { state ->
// Reset the filter asap
directoryUsersSearch.accept("")
val selectedUsers = state.selectedUsers.toggle(action.user)
setState { copy(selectedUsers = selectedUsers) }
val selectedUsers = state.pendingInvitees.toggle(action.pendingInvitee)
setState { copy(pendingInvitees = selectedUsers) }
}
private fun observeDirectoryUsers() = withState { state ->

View File

@ -27,11 +27,21 @@ data class UserDirectoryViewState(
val excludedUserIds: Set<String>? = null,
val knownUsers: Async<PagedList<User>> = Uninitialized,
val directoryUsers: Async<List<User>> = Uninitialized,
val selectedUsers: Set<User> = emptySet(),
val pendingInvitees: Set<PendingInvitee> = emptySet(),
val createAndInviteState: Async<String> = Uninitialized,
val directorySearchTerm: String = "",
val filterKnownUsersValue: Option<String> = Option.empty()
) : MvRxState {
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_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
android:id="@+id/recyclerView"
android:layout_width="0dp"
@ -134,7 +151,7 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/addByMatrixId"
app:layout_constraintTop_toBottomOf="@+id/addFromPhoneBook"
tools:listitem="@layout/item_known_user" />
</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="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>