Merge branch 'develop' into feature/crash_manual_verify

This commit is contained in:
Valere 2020-05-13 16:35:28 +02:00 committed by GitHub
commit 650b6bd9ea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
99 changed files with 2422 additions and 562 deletions

View File

@ -2,19 +2,22 @@ Changes in RiotX 0.20.0 (2020-XX-XX)
===================================================
Features ✨:
-
- Add Direct Shortcuts (#652)
Improvements 🙌:
-
- Invite member(s) to an existing room (#1276)
- Improve notification accessibility with ticker text (#1226)
- Support homeserver discovery from MXID (DISABLED: waiting for design) (#476)
Bugfix 🐛:
- Fix | Verify Manually by Text crashes if private SSK not known (#1337)
- Sometimes the same device appears twice in the list of devices of a user (#1329)
Translations 🗣:
-
SDK API changes ⚠️:
-
- excludedUserIds parameter add to to UserService.getPagedUsersLive() function
Build 🧱:
-

View File

@ -8,7 +8,7 @@
# The setting is particularly useful for tweaking memory settings.
android.enableJetifier=true
android.useAndroidX=true
org.gradle.jvmargs=-Xmx1536m
org.gradle.jvmargs=-Xmx8192m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects

View File

@ -28,6 +28,7 @@ import im.vector.matrix.android.api.session.room.send.UserDraft
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.util.Optional
import im.vector.matrix.android.api.util.toOptional
import io.reactivex.Completable
import io.reactivex.Observable
import io.reactivex.Single
@ -95,6 +96,10 @@ class RxRoom(private val room: Room) {
fun liveNotificationState(): Observable<RoomNotificationState> {
return room.getLiveRoomNotificationState().asObservable()
}
fun invite(userId: String, reason: String? = null): Completable = completableBuilder<Unit> {
room.invite(userId, reason, it)
}
}
fun Room.rx(): RxRoom {

View File

@ -90,8 +90,8 @@ class RxSession(private val session: Session) {
return session.getIgnoredUsersLive().asObservable()
}
fun livePagedUsers(filter: String? = null): Observable<PagedList<User>> {
return session.getPagedUsersLive(filter).asObservable()
fun livePagedUsers(filter: String? = null, excludedUserIds: Set<String>? = null): Observable<PagedList<User>> {
return session.getPagedUsersLive(filter, excludedUserIds).asObservable()
}
fun createRoom(roomParams: CreateRoomParams): Single<String> = singleBuilder {

View File

@ -23,6 +23,7 @@ import im.vector.matrix.android.api.auth.data.LoginFlowResult
import im.vector.matrix.android.api.auth.data.SessionParams
import im.vector.matrix.android.api.auth.login.LoginWizard
import im.vector.matrix.android.api.auth.registration.RegistrationWizard
import im.vector.matrix.android.api.auth.wellknown.WellknownResult
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.util.Cancelable
@ -30,7 +31,6 @@ import im.vector.matrix.android.api.util.Cancelable
* This interface defines methods to authenticate or to create an account to a matrix server.
*/
interface AuthenticationService {
/**
* Request the supported login flows for this homeserver.
* This is the first method to call to be able to get a wizard to login or the create an account
@ -89,4 +89,20 @@ interface AuthenticationService {
fun createSessionFromSso(homeServerConnectionConfig: HomeServerConnectionConfig,
credentials: Credentials,
callback: MatrixCallback<Session>): Cancelable
/**
* Perform a wellknown request, using the domain from the matrixId
*/
fun getWellKnownData(matrixId: String,
callback: MatrixCallback<WellknownResult>): Cancelable
/**
* Authenticate with a matrixId and a password
* Usually call this after a successful call to getWellKnownData()
*/
fun directAuthentication(homeServerConnectionConfig: HomeServerConnectionConfig,
matrixId: String,
password: String,
initialDeviceName: String,
callback: MatrixCallback<Session>): Cancelable
}

View File

@ -24,16 +24,38 @@ import im.vector.matrix.android.internal.util.md5
* This data class hold credentials user data.
* You shouldn't have to instantiate it.
* The access token should be use to authenticate user in all server requests.
* Ref: https://matrix.org/docs/spec/client_server/latest#post-matrix-client-r0-login
*/
@JsonClass(generateAdapter = true)
data class Credentials(
/**
* The fully-qualified Matrix ID that has been registered.
*/
@Json(name = "user_id") val userId: String,
@Json(name = "home_server") val homeServer: String,
/**
* An access token for the account. This access token can then be used to authorize other requests.
*/
@Json(name = "access_token") val accessToken: String,
/**
* Not documented
*/
@Json(name = "refresh_token") val refreshToken: String?,
/**
* The server_name of the homeserver on which the account has been registered.
* @Deprecated. Clients should extract the server_name from user_id (by splitting at the first colon)
* if they require it. Note also that homeserver is not spelt this way.
*/
@Json(name = "home_server") val homeServer: String,
/**
* ID of the logged-in device. Will be the same as the corresponding parameter in the request, if one was specified.
*/
@Json(name = "device_id") val deviceId: String?,
// Optional data that may contain info to override home server and/or identity server
@Json(name = "well_known") val wellKnown: WellKnown? = null
/**
* Optional client configuration provided by the server. If present, clients SHOULD use the provided object to
* reconfigure themselves, optionally validating the URLs within.
* This object takes the same form as the one returned from .well-known autodiscovery.
*/
@Json(name = "well_known") val discoveryInformation: DiscoveryInformation? = null
)
internal fun Credentials.sessionId(): String {

View File

@ -0,0 +1,40 @@
/*
* 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.auth.data
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* This is a light version of Wellknown model, used for login response
* Ref: https://matrix.org/docs/spec/client_server/latest#post-matrix-client-r0-login
*/
@JsonClass(generateAdapter = true)
data class DiscoveryInformation(
/**
* Required. Used by clients to discover homeserver information.
*/
@Json(name = "m.homeserver")
val homeServer: WellKnownBaseConfig? = null,
/**
* Used by clients to discover identity server information.
* Note: matrix.org does not send this field
*/
@Json(name = "m.identity_server")
val identityServer: WellKnownBaseConfig? = null
)

View File

@ -18,6 +18,7 @@ package im.vector.matrix.android.api.auth.data
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import im.vector.matrix.android.api.util.JsonDict
/**
* https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery
@ -52,7 +53,7 @@ data class WellKnown(
val identityServer: WellKnownBaseConfig? = null,
@Json(name = "m.integrations")
val integrations: Map<String, @JvmSuppressWildcards Any>? = null
val integrations: JsonDict? = null
) {
/**
* Returns the list of integration managers proposed

View File

@ -16,6 +16,6 @@
package im.vector.matrix.android.api.auth.data
data class WellKnownManagerConfig(
val apiUrl : String,
val apiUrl: String,
val uiUrl: String
)

View File

@ -0,0 +1,55 @@
/*
* 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.auth.wellknown
import im.vector.matrix.android.api.auth.data.WellKnown
/**
* Ref: https://matrix.org/docs/spec/client_server/latest#well-known-uri
*/
sealed class WellknownResult {
/**
* The provided matrixId is no valid. Unable to extract a domain name.
*/
object InvalidMatrixId : WellknownResult()
/**
* Retrieve the specific piece of information from the user in a way which fits within the existing client user experience,
* if the client is inclined to do so. Failure can take place instead if no good user experience for this is possible at this point.
*/
data class Prompt(val homeServerUrl: String,
val identityServerUrl: String?,
val wellKnown: WellKnown) : WellknownResult()
/**
* Stop the current auto-discovery mechanism. If no more auto-discovery mechanisms are available,
* then the client may use other methods of determining the required parameters, such as prompting the user, or using default values.
*/
object Ignore : WellknownResult()
/**
* Inform the user that auto-discovery failed due to invalid/empty data and PROMPT for the parameter.
*/
object FailPrompt : WellknownResult()
/**
* Inform the user that auto-discovery did not return any usable URLs. Do not continue further with the current login process.
* At this point, valid data was obtained, but no homeserver is available to serve the client.
* No further guess should be attempted and the user should make a conscientious decision what to do next.
*/
object FailError : WellknownResult()
}

View File

@ -61,9 +61,10 @@ interface UserService {
/**
* Observe a live [PagedList] of users sorted alphabetically. You can filter the users.
* @param filter the filter. It will look into userId and displayName.
* @param excludedUserIds userId list which will be excluded from the result list.
* @return a Livedata of users
*/
fun getPagedUsersLive(filter: String? = null): LiveData<PagedList<User>>
fun getPagedUsersLive(filter: String? = null, excludedUserIds: Set<String>? = null): LiveData<PagedList<User>>
/**
* Get list of ignored users

View File

@ -25,6 +25,10 @@ import im.vector.matrix.android.internal.auth.db.AuthRealmMigration
import im.vector.matrix.android.internal.auth.db.AuthRealmModule
import im.vector.matrix.android.internal.auth.db.RealmPendingSessionStore
import im.vector.matrix.android.internal.auth.db.RealmSessionParamsStore
import im.vector.matrix.android.internal.auth.wellknown.DefaultDirectLoginTask
import im.vector.matrix.android.internal.auth.wellknown.DefaultGetWellknownTask
import im.vector.matrix.android.internal.auth.wellknown.DirectLoginTask
import im.vector.matrix.android.internal.auth.wellknown.GetWellknownTask
import im.vector.matrix.android.internal.database.RealmKeysUtils
import im.vector.matrix.android.internal.di.AuthDatabase
import io.realm.RealmConfiguration
@ -59,14 +63,20 @@ internal abstract class AuthModule {
}
@Binds
abstract fun bindSessionParamsStore(sessionParamsStore: RealmSessionParamsStore): SessionParamsStore
abstract fun bindSessionParamsStore(store: RealmSessionParamsStore): SessionParamsStore
@Binds
abstract fun bindPendingSessionStore(pendingSessionStore: RealmPendingSessionStore): PendingSessionStore
abstract fun bindPendingSessionStore(store: RealmPendingSessionStore): PendingSessionStore
@Binds
abstract fun bindAuthenticationService(authenticationService: DefaultAuthenticationService): AuthenticationService
abstract fun bindAuthenticationService(service: DefaultAuthenticationService): AuthenticationService
@Binds
abstract fun bindSessionCreator(sessionCreator: DefaultSessionCreator): SessionCreator
abstract fun bindSessionCreator(creator: DefaultSessionCreator): SessionCreator
@Binds
abstract fun bindGetWellknownTask(task: DefaultGetWellknownTask): GetWellknownTask
@Binds
abstract fun bindDirectLoginTask(task: DefaultDirectLoginTask): DirectLoginTask
}

View File

@ -29,6 +29,7 @@ import im.vector.matrix.android.api.auth.data.isLoginAndRegistrationSupportedByS
import im.vector.matrix.android.api.auth.data.isSupportedBySdk
import im.vector.matrix.android.api.auth.login.LoginWizard
import im.vector.matrix.android.api.auth.registration.RegistrationWizard
import im.vector.matrix.android.api.auth.wellknown.WellknownResult
import im.vector.matrix.android.api.failure.Failure
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.util.Cancelable
@ -38,11 +39,16 @@ import im.vector.matrix.android.internal.auth.data.RiotConfig
import im.vector.matrix.android.internal.auth.db.PendingSessionData
import im.vector.matrix.android.internal.auth.login.DefaultLoginWizard
import im.vector.matrix.android.internal.auth.registration.DefaultRegistrationWizard
import im.vector.matrix.android.internal.auth.wellknown.DirectLoginTask
import im.vector.matrix.android.internal.auth.wellknown.GetWellknownTask
import im.vector.matrix.android.internal.di.Unauthenticated
import im.vector.matrix.android.internal.network.RetrofitFactory
import im.vector.matrix.android.internal.network.executeRequest
import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith
import im.vector.matrix.android.internal.task.launchToCallback
import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers
import im.vector.matrix.android.internal.util.exhaustive
import im.vector.matrix.android.internal.util.toCancelable
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@ -59,7 +65,10 @@ internal class DefaultAuthenticationService @Inject constructor(
private val sessionParamsStore: SessionParamsStore,
private val sessionManager: SessionManager,
private val sessionCreator: SessionCreator,
private val pendingSessionStore: PendingSessionStore
private val pendingSessionStore: PendingSessionStore,
private val getWellknownTask: GetWellknownTask,
private val directLoginTask: DirectLoginTask,
private val taskExecutor: TaskExecutor
) : AuthenticationService {
private var pendingSessionData: PendingSessionData? = pendingSessionStore.getPendingSessionData()
@ -148,27 +157,71 @@ internal class DefaultAuthenticationService @Inject constructor(
val authAPI = buildAuthAPI(homeServerConnectionConfig)
// Ok, try to get the config.json file of a RiotWeb client
val riotConfig = executeRequest<RiotConfig>(null) {
apiCall = authAPI.getRiotConfig()
}
if (riotConfig.defaultHomeServerUrl?.isNotBlank() == true) {
// Ok, good sign, we got a default hs url
val newHomeServerConnectionConfig = homeServerConnectionConfig.copy(
homeServerUri = Uri.parse(riotConfig.defaultHomeServerUrl)
)
val newAuthAPI = buildAuthAPI(newHomeServerConnectionConfig)
val versions = executeRequest<Versions>(null) {
apiCall = newAuthAPI.versions()
return runCatching {
executeRequest<RiotConfig>(null) {
apiCall = authAPI.getRiotConfig()
}
return getLoginFlowResult(newAuthAPI, versions, riotConfig.defaultHomeServerUrl)
} else {
// Config exists, but there is no default homeserver url (ex: https://riot.im/app)
throw Failure.OtherServerError("", HttpsURLConnection.HTTP_NOT_FOUND /* 404 */)
}
.map { riotConfig ->
if (riotConfig.defaultHomeServerUrl?.isNotBlank() == true) {
// Ok, good sign, we got a default hs url
val newHomeServerConnectionConfig = homeServerConnectionConfig.copy(
homeServerUri = Uri.parse(riotConfig.defaultHomeServerUrl)
)
val newAuthAPI = buildAuthAPI(newHomeServerConnectionConfig)
val versions = executeRequest<Versions>(null) {
apiCall = newAuthAPI.versions()
}
getLoginFlowResult(newAuthAPI, versions, riotConfig.defaultHomeServerUrl)
} else {
// Config exists, but there is no default homeserver url (ex: https://riot.im/app)
throw Failure.OtherServerError("", HttpsURLConnection.HTTP_NOT_FOUND /* 404 */)
}
}
.fold(
{
it
},
{
if (it is Failure.OtherServerError
&& it.httpCode == HttpsURLConnection.HTTP_NOT_FOUND /* 404 */) {
// Try with wellknown
getWellknownLoginFlowInternal(homeServerConnectionConfig)
} else {
throw it
}
}
)
}
private suspend fun getWellknownLoginFlowInternal(homeServerConnectionConfig: HomeServerConnectionConfig): LoginFlowResult {
val domain = homeServerConnectionConfig.homeServerUri.host
?: throw Failure.OtherServerError("", HttpsURLConnection.HTTP_NOT_FOUND /* 404 */)
// Create a fake userId, for the getWellknown task
val fakeUserId = "@alice:$domain"
val wellknownResult = getWellknownTask.execute(GetWellknownTask.Params(fakeUserId))
return when (wellknownResult) {
is WellknownResult.Prompt -> {
val newHomeServerConnectionConfig = homeServerConnectionConfig.copy(
homeServerUri = Uri.parse(wellknownResult.homeServerUrl),
identityServerUri = wellknownResult.identityServerUrl?.let { Uri.parse(it) }
)
val newAuthAPI = buildAuthAPI(newHomeServerConnectionConfig)
val versions = executeRequest<Versions>(null) {
apiCall = newAuthAPI.versions()
}
getLoginFlowResult(newAuthAPI, versions, wellknownResult.homeServerUrl)
}
else -> throw Failure.OtherServerError("", HttpsURLConnection.HTTP_NOT_FOUND /* 404 */)
}.exhaustive
}
private suspend fun getLoginFlowResult(authAPI: AuthAPI, versions: Versions, homeServerUrl: String): LoginFlowResult {
@ -260,6 +313,26 @@ internal class DefaultAuthenticationService @Inject constructor(
}
}
override fun getWellKnownData(matrixId: String, callback: MatrixCallback<WellknownResult>): Cancelable {
return getWellknownTask
.configureWith(GetWellknownTask.Params(matrixId)) {
this.callback = callback
}
.executeBy(taskExecutor)
}
override fun directAuthentication(homeServerConnectionConfig: HomeServerConnectionConfig,
matrixId: String,
password: String,
initialDeviceName: String,
callback: MatrixCallback<Session>): Cancelable {
return directLoginTask
.configureWith(DirectLoginTask.Params(homeServerConnectionConfig, matrixId, password, initialDeviceName)) {
this.callback = callback
}
.executeBy(taskExecutor)
}
private suspend fun createSessionFromSso(credentials: Credentials,
homeServerConnectionConfig: HomeServerConnectionConfig): Session = withContext(coroutineDispatchers.computation) {
sessionCreator.createSession(credentials, homeServerConnectionConfig)

View File

@ -46,14 +46,14 @@ internal class DefaultSessionCreator @Inject constructor(
val sessionParams = SessionParams(
credentials = credentials,
homeServerConnectionConfig = homeServerConnectionConfig.copy(
homeServerUri = credentials.wellKnown?.homeServer?.baseURL
homeServerUri = credentials.discoveryInformation?.homeServer?.baseURL
// remove trailing "/"
?.trim { it == '/' }
?.takeIf { it.isNotBlank() }
?.also { Timber.d("Overriding homeserver url to $it") }
?.let { Uri.parse(it) }
?: homeServerConnectionConfig.homeServerUri,
identityServerUri = credentials.wellKnown?.identityServer?.baseURL
identityServerUri = credentials.discoveryInformation?.identityServer?.baseURL
// remove trailing "/"
?.trim { it == '/' }
?.takeIf { it.isNotBlank() }

View File

@ -0,0 +1,61 @@
/*
* 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.auth.wellknown
import dagger.Lazy
import im.vector.matrix.android.api.auth.data.Credentials
import im.vector.matrix.android.api.auth.data.HomeServerConnectionConfig
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.internal.auth.AuthAPI
import im.vector.matrix.android.internal.auth.SessionCreator
import im.vector.matrix.android.internal.auth.data.PasswordLoginParams
import im.vector.matrix.android.internal.di.Unauthenticated
import im.vector.matrix.android.internal.network.RetrofitFactory
import im.vector.matrix.android.internal.network.executeRequest
import im.vector.matrix.android.internal.task.Task
import okhttp3.OkHttpClient
import javax.inject.Inject
internal interface DirectLoginTask : Task<DirectLoginTask.Params, Session> {
data class Params(
val homeServerConnectionConfig: HomeServerConnectionConfig,
val userId: String,
val password: String,
val deviceName: String
)
}
internal class DefaultDirectLoginTask @Inject constructor(
@Unauthenticated
private val okHttpClient: Lazy<OkHttpClient>,
private val retrofitFactory: RetrofitFactory,
private val sessionCreator: SessionCreator
) : DirectLoginTask {
override suspend fun execute(params: DirectLoginTask.Params): Session {
val authAPI = retrofitFactory.create(okHttpClient, params.homeServerConnectionConfig.homeServerUri.toString())
.create(AuthAPI::class.java)
val loginParams = PasswordLoginParams.userIdentifier(params.userId, params.password, params.deviceName)
val credentials = executeRequest<Credentials>(null) {
apiCall = authAPI.login(loginParams)
}
return sessionCreator.createSession(credentials, params.homeServerConnectionConfig)
}
}

View File

@ -0,0 +1,199 @@
/*
* 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.auth.wellknown
import android.util.MalformedJsonException
import dagger.Lazy
import im.vector.matrix.android.api.MatrixPatterns
import im.vector.matrix.android.api.auth.data.WellKnown
import im.vector.matrix.android.api.auth.wellknown.WellknownResult
import im.vector.matrix.android.api.failure.Failure
import im.vector.matrix.android.internal.di.Unauthenticated
import im.vector.matrix.android.internal.identity.IdentityPingApi
import im.vector.matrix.android.internal.network.RetrofitFactory
import im.vector.matrix.android.internal.network.executeRequest
import im.vector.matrix.android.internal.session.homeserver.CapabilitiesAPI
import im.vector.matrix.android.internal.task.Task
import im.vector.matrix.android.internal.util.isValidUrl
import okhttp3.OkHttpClient
import java.io.EOFException
import javax.inject.Inject
import javax.net.ssl.HttpsURLConnection
internal interface GetWellknownTask : Task<GetWellknownTask.Params, WellknownResult> {
data class Params(
val matrixId: String
)
}
/**
* Inspired from AutoDiscovery class from legacy Matrix Android SDK
*/
internal class DefaultGetWellknownTask @Inject constructor(
@Unauthenticated
private val okHttpClient: Lazy<OkHttpClient>,
private val retrofitFactory: RetrofitFactory
) : GetWellknownTask {
override suspend fun execute(params: GetWellknownTask.Params): WellknownResult {
if (!MatrixPatterns.isUserId(params.matrixId)) {
return WellknownResult.InvalidMatrixId
}
val homeServerDomain = params.matrixId.substringAfter(":")
return findClientConfig(homeServerDomain)
}
/**
* Find client config
*
* - Do the .well-known request
* - validate homeserver url and identity server url if provide in .well-known result
* - return action and .well-known data
*
* @param domain: homeserver domain, deduced from mx userId (ex: "matrix.org" from userId "@user:matrix.org")
*/
private suspend fun findClientConfig(domain: String): WellknownResult {
val wellKnownAPI = retrofitFactory.create(okHttpClient, "https://dummy.org")
.create(WellKnownAPI::class.java)
return try {
val wellKnown = executeRequest<WellKnown>(null) {
apiCall = wellKnownAPI.getWellKnown(domain)
}
// Success
val homeServerBaseUrl = wellKnown.homeServer?.baseURL
if (homeServerBaseUrl.isNullOrBlank()) {
WellknownResult.FailPrompt
} else {
if (homeServerBaseUrl.isValidUrl()) {
// Check that HS is a real one
validateHomeServer(homeServerBaseUrl, wellKnown)
} else {
WellknownResult.FailError
}
}
} catch (throwable: Throwable) {
when (throwable) {
is Failure.NetworkConnection -> {
WellknownResult.Ignore
}
is Failure.OtherServerError -> {
when (throwable.httpCode) {
HttpsURLConnection.HTTP_NOT_FOUND -> WellknownResult.Ignore
else -> WellknownResult.FailPrompt
}
}
is MalformedJsonException, is EOFException -> {
WellknownResult.FailPrompt
}
else -> {
throw throwable
}
}
}
}
/**
* Return true if home server is valid, and (if applicable) if identity server is pingable
*/
private suspend fun validateHomeServer(homeServerBaseUrl: String, wellKnown: WellKnown): WellknownResult {
val capabilitiesAPI = retrofitFactory.create(okHttpClient, homeServerBaseUrl)
.create(CapabilitiesAPI::class.java)
try {
executeRequest<Unit>(null) {
apiCall = capabilitiesAPI.getVersions()
}
} catch (throwable: Throwable) {
return WellknownResult.FailError
}
return if (wellKnown.identityServer == null) {
// No identity server
WellknownResult.Prompt(homeServerBaseUrl, null, wellKnown)
} else {
// if m.identity_server is present it must be valid
val identityServerBaseUrl = wellKnown.identityServer.baseURL
if (identityServerBaseUrl.isNullOrBlank()) {
WellknownResult.FailError
} else {
if (identityServerBaseUrl.isValidUrl()) {
if (validateIdentityServer(identityServerBaseUrl)) {
// All is ok
WellknownResult.Prompt(homeServerBaseUrl, identityServerBaseUrl, wellKnown)
} else {
WellknownResult.FailError
}
} else {
WellknownResult.FailError
}
}
}
}
/**
* Return true if identity server is pingable
*/
private suspend fun validateIdentityServer(identityServerBaseUrl: String): Boolean {
val identityPingApi = retrofitFactory.create(okHttpClient, identityServerBaseUrl)
.create(IdentityPingApi::class.java)
return try {
executeRequest<Unit>(null) {
apiCall = identityPingApi.ping()
}
true
} catch (throwable: Throwable) {
false
}
}
/**
* Try to get an identity server URL from a home server URL, using a .wellknown request
*/
/*
fun getIdentityServer(homeServerUrl: String, callback: ApiCallback<String?>) {
if (homeServerUrl.startsWith("https://")) {
wellKnownRestClient.getWellKnown(homeServerUrl.substring("https://".length),
object : SimpleApiCallback<WellKnown>(callback) {
override fun onSuccess(info: WellKnown) {
callback.onSuccess(info.identityServer?.baseURL)
}
})
} else {
callback.onUnexpectedError(InvalidParameterException("malformed url"))
}
}
fun getServerPreferredIntegrationManagers(homeServerUrl: String, callback: ApiCallback<List<WellKnownManagerConfig>>) {
if (homeServerUrl.startsWith("https://")) {
wellKnownRestClient.getWellKnown(homeServerUrl.substring("https://".length),
object : SimpleApiCallback<WellKnown>(callback) {
override fun onSuccess(info: WellKnown) {
callback.onSuccess(info.getIntegrationManagers())
}
})
} else {
callback.onUnexpectedError(InvalidParameterException("malformed url"))
}
}
*/
}

View File

@ -0,0 +1,26 @@
/*
* 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.auth.wellknown
import im.vector.matrix.android.api.auth.data.WellKnown
import retrofit2.Call
import retrofit2.http.GET
import retrofit2.http.Path
internal interface WellKnownAPI {
@GET("https://{domain}/.well-known/matrix/client")
fun getWellKnown(@Path("domain") domain: String): Call<WellKnown>
}

View File

@ -446,7 +446,7 @@ internal class DefaultCryptoService @Inject constructor(
}
override fun getCryptoDeviceInfo(userId: String): List<CryptoDeviceInfo> {
return cryptoStore.getUserDevices(userId)?.map { it.value }?.sortedBy { it.deviceId } ?: emptyList()
return cryptoStore.getUserDeviceList(userId) ?: emptyList()
}
override fun getLiveCryptoDeviceInfo(): LiveData<List<CryptoDeviceInfo>> {

View File

@ -48,7 +48,7 @@ internal class SetDeviceVerificationAction @Inject constructor(
if (device.trustLevel != trustLevel) {
device.trustLevel = trustLevel
cryptoStore.storeUserDevice(userId, device)
cryptoStore.setDeviceTrust(userId, deviceId, trustLevel.crossSigningVerified, trustLevel.locallyVerified)
}
}
}

View File

@ -164,14 +164,6 @@ internal interface IMXCryptoStore {
*/
fun saveOlmAccount()
/**
* Store a device for a user.
*
* @param userId the user's id.
* @param device the device to store.
*/
fun storeUserDevice(userId: String?, deviceInfo: CryptoDeviceInfo?)
/**
* Retrieve a device for a user.
*
@ -415,7 +407,7 @@ internal interface IMXCryptoStore {
fun getKeyBackupRecoveryKeyInfo() : SavedKeyBackupKeyInfo?
fun setUserKeysAsTrusted(userId: String, trusted: Boolean = true)
fun setDeviceTrust(userId: String, deviceId: String, crossSignedVerified: Boolean, locallyVerified: Boolean)
fun setDeviceTrust(userId: String, deviceId: String, crossSignedVerified: Boolean, locallyVerified: Boolean?)
fun clearOtherUserTrust()

View File

@ -233,29 +233,6 @@ internal class RealmCryptoStore @Inject constructor(
return olmAccount!!
}
override fun storeUserDevice(userId: String?, deviceInfo: CryptoDeviceInfo?) {
if (userId == null || deviceInfo == null) {
return
}
doRealmTransaction(realmConfiguration) { realm ->
val user = UserEntity.getOrCreate(realm, userId)
// Create device info
val deviceInfoEntity = CryptoMapper.mapToEntity(deviceInfo)
realm.insertOrUpdate(deviceInfoEntity)
// val deviceInfoEntity = DeviceInfoEntity.getOrCreate(it, userId, deviceInfo.deviceId).apply {
// deviceId = deviceInfo.deviceId
// identityKey = deviceInfo.identityKey()
// putDeviceInfo(deviceInfo)
// }
if (!user.devices.contains(deviceInfoEntity)) {
user.devices.add(deviceInfoEntity)
}
}
}
override fun getUserDevice(userId: String, deviceId: String): CryptoDeviceInfo? {
return doWithRealm(realmConfiguration) {
it.where<DeviceInfoEntity>()
@ -1276,7 +1253,7 @@ internal class RealmCryptoStore @Inject constructor(
}
}
override fun setDeviceTrust(userId: String, deviceId: String, crossSignedVerified: Boolean, locallyVerified: Boolean) {
override fun setDeviceTrust(userId: String, deviceId: String, crossSignedVerified: Boolean, locallyVerified: Boolean?) {
doRealmTransaction(realmConfiguration) { realm ->
realm.where(DeviceInfoEntity::class.java)
.equalTo(DeviceInfoEntityFields.PRIMARY_KEY, DeviceInfoEntity.createPrimaryKey(userId, deviceId))
@ -1289,7 +1266,7 @@ internal class RealmCryptoStore @Inject constructor(
deviceInfoEntity.trustLevelEntity = it
}
} else {
trustEntity.locallyVerified = locallyVerified
locallyVerified?.let { trustEntity.locallyVerified = it }
trustEntity.crossSignedVerified = crossSignedVerified
}
}

View File

@ -45,7 +45,7 @@ internal class RealmCryptoStoreMigration @Inject constructor(private val crossSi
// Version 1L added Cross Signing info persistence
companion object {
const val CRYPTO_STORE_SCHEMA_VERSION = 5L
const val CRYPTO_STORE_SCHEMA_VERSION = 6L
}
override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) {
@ -56,6 +56,7 @@ internal class RealmCryptoStoreMigration @Inject constructor(private val crossSi
if (oldVersion <= 2) migrateTo3(realm)
if (oldVersion <= 3) migrateTo4(realm)
if (oldVersion <= 4) migrateTo5(realm)
if (oldVersion <= 5) migrateTo6(realm)
}
private fun migrateTo1(realm: DynamicRealm) {
@ -255,4 +256,22 @@ internal class RealmCryptoStoreMigration @Inject constructor(private val crossSi
}
}
}
// Fixes duplicate devices in UserEntity#devices
private fun migrateTo6(realm: DynamicRealm) {
val userEntities = realm.where("UserEntity").findAll()
userEntities.forEach {
try {
val deviceList = it.getList(UserEntityFields.DEVICES.`$`)
?: return@forEach
val distinct = deviceList.distinctBy { it.getString(DeviceInfoEntityFields.DEVICE_ID) }
if (distinct.size != deviceList.size) {
deviceList.clear()
deviceList.addAll(distinct)
}
} catch (failure: Throwable) {
Timber.w(failure, "Crypto Data base migration error for migrateTo6")
}
}
}
}

View File

@ -0,0 +1,34 @@
/*
* 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.identity
import im.vector.matrix.android.internal.network.NetworkConstants
import retrofit2.Call
import retrofit2.http.GET
internal interface IdentityPingApi {
/**
* https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery
* Simple ping call to check if server alive
*
* Ref: https://matrix.org/docs/spec/identity_service/unstable#status-check
*
* @return 200 in case of success
*/
@GET(NetworkConstants.URI_API_PREFIX_IDENTITY)
fun ping(): Call<Unit>
}

View File

@ -26,4 +26,10 @@ internal object NetworkConstants {
// Media
private const val URI_API_MEDIA_PREFIX_PATH = "_matrix/media"
const val URI_API_MEDIA_PREFIX_PATH_R0 = "$URI_API_MEDIA_PREFIX_PATH/r0/"
// Identity server
const val URI_IDENTITY_PATH = "_matrix/identity/api/v1/"
const val URI_IDENTITY_PATH_V2 = "_matrix/identity/v2/"
const val URI_API_PREFIX_IDENTITY = "_matrix/identity/api/v1"
}

View File

@ -36,6 +36,7 @@ import im.vector.matrix.android.internal.session.filter.FilterModule
import im.vector.matrix.android.internal.session.group.GetGroupDataWorker
import im.vector.matrix.android.internal.session.group.GroupModule
import im.vector.matrix.android.internal.session.homeserver.HomeServerCapabilitiesModule
import im.vector.matrix.android.internal.session.openid.OpenIdModule
import im.vector.matrix.android.internal.session.profile.ProfileModule
import im.vector.matrix.android.internal.session.pushers.AddHttpPusherWorker
import im.vector.matrix.android.internal.session.pushers.PushersModule
@ -70,6 +71,7 @@ import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers
CacheModule::class,
CryptoModule::class,
PushersModule::class,
OpenIdModule::class,
AccountDataModule::class,
ProfileModule::class,
SessionAssistedInjectModule::class,

View File

@ -0,0 +1,37 @@
/*
* 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.openid
import im.vector.matrix.android.internal.di.UserId
import im.vector.matrix.android.internal.network.executeRequest
import im.vector.matrix.android.internal.task.Task
import org.greenrobot.eventbus.EventBus
import javax.inject.Inject
internal interface GetOpenIdTokenTask : Task<Unit, RequestOpenIdTokenResponse>
internal class DefaultGetOpenIdTokenTask @Inject constructor(
@UserId private val userId: String,
private val openIdAPI: OpenIdAPI,
private val eventBus: EventBus) : GetOpenIdTokenTask {
override suspend fun execute(params: Unit): RequestOpenIdTokenResponse {
return executeRequest(eventBus) {
apiCall = openIdAPI.openIdToken(userId)
}
}
}

View File

@ -0,0 +1,38 @@
/*
* 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.openid
import im.vector.matrix.android.api.util.JsonDict
import im.vector.matrix.android.internal.network.NetworkConstants
import retrofit2.Call
import retrofit2.http.Body
import retrofit2.http.POST
import retrofit2.http.Path
internal interface OpenIdAPI {
/**
* Gets a bearer token from the homeserver that the user can
* present to a third party in order to prove their ownership
* of the Matrix account they are logged into.
* Ref: https://matrix.org/docs/spec/client_server/latest#post-matrix-client-r0-user-userid-openid-request-token
*
* @param userId the user id
*/
@POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "user/{userId}/openid/request_token")
fun openIdToken(@Path("userId") userId: String, @Body body: JsonDict = emptyMap()): Call<RequestOpenIdTokenResponse>
}

View File

@ -0,0 +1,38 @@
/*
* 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.openid
import dagger.Binds
import dagger.Module
import dagger.Provides
import retrofit2.Retrofit
@Module
internal abstract class OpenIdModule {
@Module
companion object {
@JvmStatic
@Provides
fun providesOpenIdAPI(retrofit: Retrofit): OpenIdAPI {
return retrofit.create(OpenIdAPI::class.java)
}
}
@Binds
abstract fun bindGetOpenIdTokenTask(task: DefaultGetOpenIdTokenTask): GetOpenIdTokenTask
}

View File

@ -0,0 +1,48 @@
/*
* 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.openid
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
internal data class RequestOpenIdTokenResponse(
/**
* Required. An access token the consumer may use to verify the identity of the person who generated the token.
* This is given to the federation API GET /openid/userinfo to verify the user's identity.
*/
@Json(name = "access_token")
val openIdToken: String,
/**
* Required. The string "Bearer".
*/
@Json(name = "token_type")
val tokenType: String,
/**
* Required. The homeserver domain the consumer should use when attempting to verify the user's identity.
*/
@Json(name = "matrix_server_name")
val matrixServerName: String,
/**
* Required. The number of seconds before this token expires and a new one must be generated.
*/
@Json(name = "expires_in")
val expiresIn: Int
)

View File

@ -39,6 +39,8 @@ internal class DefaultInviteTask @Inject constructor(
return executeRequest(eventBus) {
val body = InviteBody(params.userId, params.reason)
apiCall = roomAPI.invite(params.roomId, body)
isRetryable = true
maxRetryCount = 3
}
}
}

View File

@ -91,7 +91,7 @@ internal class DefaultUserService @Inject constructor(private val monarchy: Mona
)
}
override fun getPagedUsersLive(filter: String?): LiveData<PagedList<User>> {
override fun getPagedUsersLive(filter: String?, excludedUserIds: Set<String>?): LiveData<PagedList<User>> {
realmDataSourceFactory.updateQuery { realm ->
val query = realm.where(UserEntity::class.java)
if (filter.isNullOrEmpty()) {
@ -104,6 +104,11 @@ internal class DefaultUserService @Inject constructor(private val monarchy: Mona
.contains(UserEntityFields.USER_ID, filter)
.endGroup()
}
excludedUserIds
?.takeIf { it.isNotEmpty() }
?.let {
query.not().`in`(UserEntityFields.USER_ID, it.toTypedArray())
}
query.sort(UserEntityFields.DISPLAY_NAME)
}
return monarchy.findAllPagedWithChanges(realmDataSourceFactory, livePagedListBuilder)

View File

@ -16,7 +16,7 @@
package im.vector.matrix.android.internal.session.user
import im.vector.matrix.android.internal.network.NetworkConstants.URI_API_PREFIX_PATH_R0
import im.vector.matrix.android.internal.network.NetworkConstants
import im.vector.matrix.android.internal.session.user.model.SearchUsersParams
import im.vector.matrix.android.internal.session.user.model.SearchUsersResponse
import retrofit2.Call
@ -30,6 +30,6 @@ internal interface SearchUserAPI {
*
* @param searchUsersParams the search params.
*/
@POST(URI_API_PREFIX_PATH_R0 + "user_directory/search")
@POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "user_directory/search")
fun searchUsers(@Body searchUsersParams: SearchUsersParams): Call<SearchUsersResponse>
}

View File

@ -19,7 +19,6 @@ package im.vector.matrix.android.internal.session.user.accountdata
import im.vector.matrix.android.internal.network.NetworkConstants
import retrofit2.Call
import retrofit2.http.Body
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
@ -34,15 +33,4 @@ interface AccountDataAPI {
*/
@PUT(NetworkConstants.URI_API_PREFIX_PATH_R0 + "user/{userId}/account_data/{type}")
fun setAccountData(@Path("userId") userId: String, @Path("type") type: String, @Body params: Any): Call<Unit>
/**
* Gets a bearer token from the homeserver that the user can
* present to a third party in order to prove their ownership
* of the Matrix account they are logged into.
*
* @param userId the user id
* @param body the body content
*/
@POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "user/{userId}/openid/request_token")
fun openIdToken(@Path("userId") userId: String, @Body body: Map<Any, Any>): Call<Map<Any, Any>>
}

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,12 +14,15 @@
* limitations under the License.
*/
package im.vector.riotx.features.createdirect
package im.vector.matrix.android.internal.util
import im.vector.riotx.core.platform.VectorSharedAction
import java.net.URL
sealed class CreateDirectRoomSharedAction : VectorSharedAction {
object OpenUsersDirectory : CreateDirectRoomSharedAction()
object Close : CreateDirectRoomSharedAction()
object GoBack : CreateDirectRoomSharedAction()
internal fun String.isValidUrl(): Boolean {
return try {
URL(this)
true
} catch (t: Throwable) {
false
}
}

View File

@ -85,6 +85,7 @@
</activity>
<activity android:name=".features.debug.DebugMenuActivity" />
<activity android:name="im.vector.riotx.features.createdirect.CreateDirectRoomActivity" />
<activity android:name="im.vector.riotx.features.invite.InviteUsersToRoomActivity" />
<activity android:name=".features.webview.VectorWebViewActivity" />
<activity android:name=".features.link.LinkHandlerActivity">
<intent-filter>

View File

@ -23,8 +23,6 @@ import dagger.Binds
import dagger.Module
import dagger.multibindings.IntoMap
import im.vector.riotx.features.attachments.preview.AttachmentsPreviewFragment
import im.vector.riotx.features.createdirect.CreateDirectRoomDirectoryUsersFragment
import im.vector.riotx.features.createdirect.CreateDirectRoomKnownUsersFragment
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
@ -63,6 +61,8 @@ import im.vector.riotx.features.login.LoginSplashFragment
import im.vector.riotx.features.login.LoginWaitForEmailFragment
import im.vector.riotx.features.login.LoginWebFragment
import im.vector.riotx.features.login.terms.LoginTermsFragment
import im.vector.riotx.features.userdirectory.KnownUsersFragment
import im.vector.riotx.features.userdirectory.UserDirectoryFragment
import im.vector.riotx.features.qrcode.QrCodeScannerFragment
import im.vector.riotx.features.reactions.EmojiChooserFragment
import im.vector.riotx.features.reactions.EmojiSearchResultFragment
@ -226,13 +226,13 @@ interface FragmentModule {
@Binds
@IntoMap
@FragmentKey(CreateDirectRoomDirectoryUsersFragment::class)
fun bindCreateDirectRoomDirectoryUsersFragment(fragment: CreateDirectRoomDirectoryUsersFragment): Fragment
@FragmentKey(UserDirectoryFragment::class)
fun bindUserDirectoryFragment(fragment: UserDirectoryFragment): Fragment
@Binds
@IntoMap
@FragmentKey(CreateDirectRoomKnownUsersFragment::class)
fun bindCreateDirectRoomKnownUsersFragment(fragment: CreateDirectRoomKnownUsersFragment): Fragment
@FragmentKey(KnownUsersFragment::class)
fun bindKnownUsersFragment(fragment: KnownUsersFragment): Fragment
@Binds
@IntoMap

View File

@ -39,6 +39,7 @@ import im.vector.riotx.features.home.room.detail.timeline.reactions.ViewReaction
import im.vector.riotx.features.home.room.filtered.FilteredRoomsActivity
import im.vector.riotx.features.home.room.list.RoomListModule
import im.vector.riotx.features.home.room.list.actions.RoomListQuickActionsBottomSheet
import im.vector.riotx.features.invite.InviteUsersToRoomActivity
import im.vector.riotx.features.invite.VectorInviteView
import im.vector.riotx.features.link.LinkHandlerActivity
import im.vector.riotx.features.login.LoginActivity
@ -116,6 +117,7 @@ interface ScreenComponent {
fun inject(activity: DebugMenuActivity)
fun inject(activity: SharedSecureStorageActivity)
fun inject(activity: BigImageViewerActivity)
fun inject(activity: InviteUsersToRoomActivity)
/* ==========================================================================================
* BottomSheets

View File

@ -22,7 +22,6 @@ import dagger.Binds
import dagger.Module
import dagger.multibindings.IntoMap
import im.vector.riotx.core.platform.ConfigurationViewModel
import im.vector.riotx.features.createdirect.CreateDirectRoomSharedActionViewModel
import im.vector.riotx.features.crypto.keysbackup.restore.KeysBackupRestoreFromKeyViewModel
import im.vector.riotx.features.crypto.keysbackup.restore.KeysBackupRestoreFromPassphraseViewModel
import im.vector.riotx.features.crypto.keysbackup.restore.KeysBackupRestoreSharedViewModel
@ -31,10 +30,10 @@ import im.vector.riotx.features.home.HomeSharedActionViewModel
import im.vector.riotx.features.home.room.detail.RoomDetailSharedActionViewModel
import im.vector.riotx.features.home.room.detail.timeline.action.MessageSharedActionViewModel
import im.vector.riotx.features.home.room.list.actions.RoomListQuickActionsSharedActionViewModel
import im.vector.riotx.features.login.LoginSharedActionViewModel
import im.vector.riotx.features.reactions.EmojiChooserViewModel
import im.vector.riotx.features.roomdirectory.RoomDirectorySharedActionViewModel
import im.vector.riotx.features.roomprofile.RoomProfileSharedActionViewModel
import im.vector.riotx.features.userdirectory.UserDirectorySharedActionViewModel
import im.vector.riotx.features.workers.signout.SignOutViewModel
@Module
@ -87,8 +86,8 @@ interface ViewModelModule {
@Binds
@IntoMap
@ViewModelKey(CreateDirectRoomSharedActionViewModel::class)
fun bindCreateDirectRoomSharedActionViewModel(viewModel: CreateDirectRoomSharedActionViewModel): ViewModel
@ViewModelKey(UserDirectorySharedActionViewModel::class)
fun bindUserDirectorySharedActionViewModel(viewModel: UserDirectorySharedActionViewModel): ViewModel
@Binds
@IntoMap
@ -110,11 +109,6 @@ interface ViewModelModule {
@ViewModelKey(RoomDirectorySharedActionViewModel::class)
fun bindRoomDirectorySharedActionViewModel(viewModel: RoomDirectorySharedActionViewModel): ViewModel
@Binds
@IntoMap
@ViewModelKey(LoginSharedActionViewModel::class)
fun bindLoginSharedActionViewModel(viewModel: LoginSharedActionViewModel): ViewModel
@Binds
@IntoMap
@ViewModelKey(RoomDetailSharedActionViewModel::class)

View File

@ -26,6 +26,7 @@ import im.vector.matrix.android.api.session.Session
import im.vector.riotx.R
import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.core.extensions.hideKeyboard
import io.reactivex.android.schedulers.AndroidSchedulers
import kotlinx.android.synthetic.main.activity.*
import javax.inject.Inject
@ -107,4 +108,15 @@ abstract class SimpleFragmentActivity : VectorBaseActivity() {
}
super.onBackPressed()
}
protected fun <T : VectorViewEvents> VectorViewModel<*, *, T>.observeViewEvents(observer: (T) -> Unit) {
viewEvents
.observe()
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
hideWaitingView()
observer(it)
}
.disposeOnDestroy()
}
}

View File

@ -1,11 +1,11 @@
/*
* 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.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
* 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,
@ -20,10 +20,5 @@ import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotx.core.platform.VectorViewModelAction
sealed class CreateDirectRoomAction : VectorViewModelAction {
object CreateRoomAndInviteSelectedUsers : CreateDirectRoomAction()
data class FilterKnownUsers(val value: String) : CreateDirectRoomAction()
data class SearchDirectoryUsers(val value: String) : CreateDirectRoomAction()
object ClearFilterKnownUsers : CreateDirectRoomAction()
data class SelectUser(val user: User) : CreateDirectRoomAction()
data class RemoveSelectedUser(val user: User) : CreateDirectRoomAction()
data class CreateRoomAndInviteSelectedUsers(val selectedUsers: Set<User>) : CreateDirectRoomAction()
}

View File

@ -37,6 +37,12 @@ import im.vector.riotx.core.extensions.addFragment
import im.vector.riotx.core.extensions.addFragmentToBackstack
import im.vector.riotx.core.platform.SimpleFragmentActivity
import im.vector.riotx.core.platform.WaitingViewData
import im.vector.riotx.features.userdirectory.KnownUsersFragment
import im.vector.riotx.features.userdirectory.KnownUsersFragmentArgs
import im.vector.riotx.features.userdirectory.UserDirectoryFragment
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.activity.*
import java.net.HttpURLConnection
import javax.inject.Inject
@ -44,7 +50,8 @@ import javax.inject.Inject
class CreateDirectRoomActivity : SimpleFragmentActivity() {
private val viewModel: CreateDirectRoomViewModel by viewModel()
private lateinit var sharedActionViewModel: CreateDirectRoomSharedActionViewModel
private lateinit var sharedActionViewModel: UserDirectorySharedActionViewModel
@Inject lateinit var userDirectoryViewModelFactory: UserDirectoryViewModel.Factory
@Inject lateinit var createDirectRoomViewModelFactory: CreateDirectRoomViewModel.Factory
@Inject lateinit var errorFormatter: ErrorFormatter
@ -56,26 +63,40 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
toolbar.visibility = View.GONE
sharedActionViewModel = viewModelProvider.get(CreateDirectRoomSharedActionViewModel::class.java)
sharedActionViewModel = viewModelProvider.get(UserDirectorySharedActionViewModel::class.java)
sharedActionViewModel
.observe()
.subscribe { sharedAction ->
when (sharedAction) {
CreateDirectRoomSharedAction.OpenUsersDirectory ->
addFragmentToBackstack(R.id.container, CreateDirectRoomDirectoryUsersFragment::class.java)
CreateDirectRoomSharedAction.Close -> finish()
CreateDirectRoomSharedAction.GoBack -> onBackPressed()
UserDirectorySharedAction.OpenUsersDirectory ->
addFragmentToBackstack(R.id.container, UserDirectoryFragment::class.java)
UserDirectorySharedAction.Close -> finish()
UserDirectorySharedAction.GoBack -> onBackPressed()
is UserDirectorySharedAction.OnMenuItemSelected -> onMenuItemSelected(sharedAction)
}
}
.disposeOnDestroy()
if (isFirstCreation()) {
addFragment(R.id.container, CreateDirectRoomKnownUsersFragment::class.java)
addFragment(
R.id.container,
KnownUsersFragment::class.java,
KnownUsersFragmentArgs(
title = getString(R.string.fab_menu_create_chat),
menuResId = R.menu.vector_create_direct_room
)
)
}
viewModel.selectSubscribe(this, CreateDirectRoomViewState::createAndInviteState) {
renderCreateAndInviteState(it)
}
}
private fun onMenuItemSelected(action: UserDirectorySharedAction.OnMenuItemSelected) {
if (action.itemId == R.id.action_create_direct_room) {
viewModel.handle(CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers(action.selectedUsers))
}
}
private fun renderCreateAndInviteState(state: Async<String>) {
when (state) {
is Loading -> renderCreationLoading()

View File

@ -18,7 +18,4 @@ package im.vector.riotx.features.createdirect
import im.vector.riotx.core.platform.VectorViewEvents
/**
* Transient events for create direct room screen
*/
sealed class CreateDirectRoomViewEvents : VectorViewEvents

View File

@ -1,42 +1,31 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* * 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.
* 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.createdirect
import arrow.core.Option
import com.airbnb.mvrx.ActivityViewModelContext
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import com.jakewharton.rxrelay2.BehaviorRelay
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.util.toMatrixItem
import im.vector.matrix.android.api.session.user.model.User
import im.vector.matrix.rx.rx
import im.vector.riotx.core.extensions.toggle
import im.vector.riotx.core.platform.VectorViewModel
import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers
import java.util.concurrent.TimeUnit
private typealias KnowUsersFilter = String
private typealias DirectoryUsersSearch = String
class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted
initialState: CreateDirectRoomViewState,
@ -48,9 +37,6 @@ class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted
fun create(initialState: CreateDirectRoomViewState): CreateDirectRoomViewModel
}
private val knownUsersFilter = BehaviorRelay.createDefault<Option<KnowUsersFilter>>(Option.empty())
private val directoryUsersSearch = BehaviorRelay.create<DirectoryUsersSearch>()
companion object : MvRxViewModelFactory<CreateDirectRoomViewModel, CreateDirectRoomViewState> {
@JvmStatic
@ -60,25 +46,15 @@ class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted
}
}
init {
observeKnownUsers()
observeDirectoryUsers()
}
override fun handle(action: CreateDirectRoomAction) {
when (action) {
is CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers -> createRoomAndInviteSelectedUsers()
is CreateDirectRoomAction.FilterKnownUsers -> knownUsersFilter.accept(Option.just(action.value))
is CreateDirectRoomAction.ClearFilterKnownUsers -> knownUsersFilter.accept(Option.empty())
is CreateDirectRoomAction.SearchDirectoryUsers -> directoryUsersSearch.accept(action.value)
is CreateDirectRoomAction.SelectUser -> handleSelectUser(action)
is CreateDirectRoomAction.RemoveSelectedUser -> handleRemoveSelectedUser(action)
is CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers -> createRoomAndInviteSelectedUsers(action.selectedUsers)
}
}
private fun createRoomAndInviteSelectedUsers() = withState { currentState ->
private fun createRoomAndInviteSelectedUsers(selectedUsers: Set<User>) {
val roomParams = CreateRoomParams(
invitedUserIds = currentState.selectedUsers.map { it.userId }
invitedUserIds = selectedUsers.map { it.userId }
)
.setDirectMessage()
.enableEncryptionIfInvitedUsersSupportIt()
@ -89,52 +65,4 @@ class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted
copy(createAndInviteState = it)
}
}
private fun handleRemoveSelectedUser(action: CreateDirectRoomAction.RemoveSelectedUser) = withState { state ->
val selectedUsers = state.selectedUsers.minus(action.user)
setState { copy(selectedUsers = selectedUsers) }
}
private fun handleSelectUser(action: CreateDirectRoomAction.SelectUser) = withState { state ->
// Reset the filter asap
directoryUsersSearch.accept("")
val selectedUsers = state.selectedUsers.toggle(action.user)
setState { copy(selectedUsers = selectedUsers) }
}
private fun observeDirectoryUsers() {
directoryUsersSearch
.debounce(300, TimeUnit.MILLISECONDS)
.switchMapSingle { search ->
val stream = if (search.isBlank()) {
Single.just(emptyList())
} else {
session.rx()
.searchUsersDirectory(search, 50, emptySet())
.map { users ->
users.sortedBy { it.toMatrixItem().firstLetterOfDisplayName() }
}
}
stream.toAsync {
copy(directoryUsers = it, directorySearchTerm = search)
}
}
.subscribe()
.disposeOnClear()
}
private fun observeKnownUsers() {
knownUsersFilter
.throttleLast(300, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.switchMap {
session.rx().livePagedUsers(it.orNull())
}
.execute { async ->
copy(
knownUsers = async,
filterKnownUsersValue = knownUsersFilter.value ?: Option.empty()
)
}
}
}

View File

@ -1,41 +1,25 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* * 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.
* 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.createdirect
import androidx.paging.PagedList
import arrow.core.Option
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.Uninitialized
import im.vector.matrix.android.api.session.user.model.User
data class CreateDirectRoomViewState(
val knownUsers: Async<PagedList<User>> = Uninitialized,
val directoryUsers: Async<List<User>> = Uninitialized,
val selectedUsers: Set<User> = emptySet(),
val createAndInviteState: Async<String> = Uninitialized,
val directorySearchTerm: String = "",
val filterKnownUsersValue: Option<String> = Option.empty()
) : MvRxState {
enum class DisplayMode {
KNOWN_USERS,
DIRECTORY_USERS
}
}
val createAndInviteState: Async<String> = Uninitialized
) : MvRxState

View File

@ -17,11 +17,13 @@
package im.vector.riotx.features.home
import android.content.Context
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.widget.ImageView
import androidx.annotation.AnyThread
import androidx.annotation.UiThread
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap
import com.amulyakhare.textdrawable.TextDrawable
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.DrawableImageViewTarget
@ -72,6 +74,28 @@ class AvatarRenderer @Inject constructor(private val activeSessionHolder: Active
.into(target)
}
@AnyThread
fun shortcutDrawable(context: Context, glideRequest: GlideRequests, matrixItem: MatrixItem, iconSize: Int): Bitmap {
return glideRequest
.asBitmap()
.apply {
val resolvedUrl = resolvedUrl(matrixItem.avatarUrl)
if (resolvedUrl != null) {
load(resolvedUrl)
} else {
val avatarColor = avatarColor(matrixItem, context)
load(TextDrawable.builder()
.beginConfig()
.bold()
.endConfig()
.buildRect(matrixItem.firstLetterOfDisplayName(), avatarColor)
.toBitmap(width = iconSize, height = iconSize))
}
}
.submit(iconSize, iconSize)
.get()
}
@AnyThread
fun getCachedDrawable(glideRequest: GlideRequests, matrixItem: MatrixItem): Drawable {
return buildGlideRequest(glideRequest, matrixItem.avatarUrl)
@ -82,10 +106,7 @@ class AvatarRenderer @Inject constructor(private val activeSessionHolder: Active
@AnyThread
fun getPlaceholderDrawable(context: Context, matrixItem: MatrixItem): Drawable {
val avatarColor = when (matrixItem) {
is MatrixItem.UserItem -> ContextCompat.getColor(context, getColorFromUserId(matrixItem.id))
else -> ContextCompat.getColor(context, getColorFromRoomId(matrixItem.id))
}
val avatarColor = avatarColor(matrixItem, context)
return TextDrawable.builder()
.beginConfig()
.bold()
@ -96,11 +117,21 @@ class AvatarRenderer @Inject constructor(private val activeSessionHolder: Active
// PRIVATE API *********************************************************************************
private fun buildGlideRequest(glideRequest: GlideRequests, avatarUrl: String?): GlideRequest<Drawable> {
val resolvedUrl = activeSessionHolder.getSafeActiveSession()?.contentUrlResolver()
?.resolveThumbnail(avatarUrl, THUMBNAIL_SIZE, THUMBNAIL_SIZE, ContentUrlResolver.ThumbnailMethod.SCALE)
val resolvedUrl = resolvedUrl(avatarUrl)
return glideRequest
.load(resolvedUrl)
.apply(RequestOptions.circleCropTransform())
}
private fun resolvedUrl(avatarUrl: String?): String? {
return activeSessionHolder.getSafeActiveSession()?.contentUrlResolver()
?.resolveThumbnail(avatarUrl, THUMBNAIL_SIZE, THUMBNAIL_SIZE, ContentUrlResolver.ThumbnailMethod.SCALE)
}
private fun avatarColor(matrixItem: MatrixItem, context: Context): Int {
return when (matrixItem) {
is MatrixItem.UserItem -> ContextCompat.getColor(context, getColorFromUserId(matrixItem.id))
else -> ContextCompat.getColor(context, getColorFromRoomId(matrixItem.id))
}
}
}

View File

@ -65,6 +65,7 @@ class HomeActivity : VectorBaseActivity(), ToolbarConfigurable {
@Inject lateinit var notificationDrawerManager: NotificationDrawerManager
@Inject lateinit var vectorPreferences: VectorPreferences
@Inject lateinit var popupAlertManager: PopupAlertManager
@Inject lateinit var shortcutsHandler: ShortcutsHandler
private val drawerListener = object : DrawerLayout.SimpleDrawerListener() {
override fun onDrawerStateChanged(newState: Int) {
@ -144,6 +145,9 @@ class HomeActivity : VectorBaseActivity(), ToolbarConfigurable {
&& activeSessionHolder.getSafeActiveSession()?.hasAlreadySynced() == true) {
promptCompleteSecurityIfNeeded()
}
shortcutsHandler.observeRoomsAndBuildShortcuts()
.disposeOnDestroy()
}
private fun promptCompleteSecurityIfNeeded() {

View File

@ -0,0 +1,88 @@
/*
* 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.home
import android.content.Context
import android.graphics.Bitmap
import android.os.Build
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import im.vector.matrix.android.api.session.room.model.tag.RoomTag
import im.vector.matrix.android.api.util.toMatrixItem
import im.vector.riotx.core.glide.GlideApp
import im.vector.riotx.core.utils.DimensionConverter
import im.vector.riotx.features.home.room.detail.RoomDetailActivity
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
import javax.inject.Inject
private val useAdaptiveIcon = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
private const val adaptiveIconSizeDp = 108
private const val adaptiveIconOuterSidesDp = 18
class ShortcutsHandler @Inject constructor(
private val context: Context,
private val homeRoomListStore: HomeRoomListDataSource,
private val avatarRenderer: AvatarRenderer,
private val dimensionConverter: DimensionConverter
) {
private val adaptiveIconSize = dimensionConverter.dpToPx(adaptiveIconSizeDp)
private val adaptiveIconOuterSides = dimensionConverter.dpToPx(adaptiveIconOuterSidesDp)
private val iconSize by lazy {
if (useAdaptiveIcon) {
adaptiveIconSize - adaptiveIconOuterSides
} else {
dimensionConverter.dpToPx(72)
}
}
fun observeRoomsAndBuildShortcuts(): Disposable {
return homeRoomListStore
.observe()
.distinct()
.observeOn(Schedulers.computation())
.subscribe { rooms ->
val shortcuts = rooms
.filter { room -> room.tags.any { it.name == RoomTag.ROOM_TAG_FAVOURITE } }
.take(n = 4) // Android only allows us to create 4 shortcuts
.map { room ->
val intent = RoomDetailActivity.shortcutIntent(context, room.roomId)
val bitmap = avatarRenderer.shortcutDrawable(context, GlideApp.with(context), room.toMatrixItem(), iconSize)
ShortcutInfoCompat.Builder(context, room.roomId)
.setShortLabel(room.displayName)
.setIcon(bitmap.toProfileImageIcon())
.setIntent(intent)
.build()
}
ShortcutManagerCompat.removeAllDynamicShortcuts(context)
ShortcutManagerCompat.addDynamicShortcuts(context, shortcuts)
}
}
// PRIVATE API *********************************************************************************
private fun Bitmap.toProfileImageIcon(): IconCompat {
return if (useAdaptiveIcon) {
IconCompat.createWithAdaptiveBitmap(this)
} else {
IconCompat.createWithBitmap(this)
}
}
}

View File

@ -44,8 +44,14 @@ class RoomDetailActivity : VectorBaseActivity(), ToolbarConfigurable {
super.onCreate(savedInstanceState)
waitingView = waiting_view
if (isFirstCreation()) {
val roomDetailArgs: RoomDetailArgs = intent?.extras?.getParcelable(EXTRA_ROOM_DETAIL_ARGS)
?: return
val roomDetailArgs: RoomDetailArgs? = if (intent?.action == ACTION_ROOM_DETAILS_FROM_SHORTCUT) {
RoomDetailArgs(roomId = intent?.extras?.getString(EXTRA_ROOM_ID)!!)
} else {
intent?.extras?.getParcelable(EXTRA_ROOM_DETAIL_ARGS)
}
if (roomDetailArgs == null) return
currentRoomId = roomDetailArgs.roomId
replaceFragment(R.id.roomDetailContainer, RoomDetailFragment::class.java, roomDetailArgs)
replaceFragment(R.id.roomDetailDrawerContainer, BreadcrumbsFragment::class.java)
@ -110,11 +116,20 @@ class RoomDetailActivity : VectorBaseActivity(), ToolbarConfigurable {
companion object {
const val EXTRA_ROOM_DETAIL_ARGS = "EXTRA_ROOM_DETAIL_ARGS"
const val EXTRA_ROOM_ID = "EXTRA_ROOM_ID"
const val ACTION_ROOM_DETAILS_FROM_SHORTCUT = "ROOM_DETAILS_FROM_SHORTCUT"
fun newIntent(context: Context, roomDetailArgs: RoomDetailArgs): Intent {
return Intent(context, RoomDetailActivity::class.java).apply {
putExtra(EXTRA_ROOM_DETAIL_ARGS, roomDetailArgs)
}
}
fun shortcutIntent(context: Context, roomId: String): Intent {
return Intent(context, RoomDetailActivity::class.java).apply {
action = ACTION_ROOM_DETAILS_FROM_SHORTCUT
putExtra(EXTRA_ROOM_ID, roomId)
}
}
}
}

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.invite
import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotx.core.platform.VectorViewModelAction
sealed class InviteUsersToRoomAction : VectorViewModelAction {
data class InviteSelectedUsers(val selectedUsers: Set<User>) : InviteUsersToRoomAction()
}

View File

@ -0,0 +1,140 @@
/*
* 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.invite
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.Parcelable
import android.view.View
import androidx.appcompat.app.AlertDialog
import com.airbnb.mvrx.MvRx
import com.airbnb.mvrx.viewModel
import im.vector.matrix.android.api.failure.Failure
import im.vector.riotx.R
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.platform.SimpleFragmentActivity
import im.vector.riotx.core.platform.WaitingViewData
import im.vector.riotx.core.utils.toast
import im.vector.riotx.features.userdirectory.KnownUsersFragment
import im.vector.riotx.features.userdirectory.KnownUsersFragmentArgs
import im.vector.riotx.features.userdirectory.UserDirectoryFragment
import im.vector.riotx.features.userdirectory.UserDirectorySharedAction
import im.vector.riotx.features.userdirectory.UserDirectorySharedActionViewModel
import im.vector.riotx.features.userdirectory.UserDirectoryViewModel
import kotlinx.android.parcel.Parcelize
import kotlinx.android.synthetic.main.activity.*
import java.net.HttpURLConnection
import javax.inject.Inject
@Parcelize
data class InviteUsersToRoomArgs(val roomId: String) : Parcelable
class InviteUsersToRoomActivity : SimpleFragmentActivity() {
private val viewModel: InviteUsersToRoomViewModel by viewModel()
private lateinit var sharedActionViewModel: UserDirectorySharedActionViewModel
@Inject lateinit var userDirectoryViewModelFactory: UserDirectoryViewModel.Factory
@Inject lateinit var inviteUsersToRoomViewModelFactory: InviteUsersToRoomViewModel.Factory
@Inject lateinit var errorFormatter: ErrorFormatter
override fun injectWith(injector: ScreenComponent) {
super.injectWith(injector)
injector.inject(this)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
toolbar.visibility = View.GONE
sharedActionViewModel = viewModelProvider.get(UserDirectorySharedActionViewModel::class.java)
sharedActionViewModel
.observe()
.subscribe { sharedAction ->
when (sharedAction) {
UserDirectorySharedAction.OpenUsersDirectory ->
addFragmentToBackstack(R.id.container, UserDirectoryFragment::class.java)
UserDirectorySharedAction.Close -> finish()
UserDirectorySharedAction.GoBack -> onBackPressed()
is UserDirectorySharedAction.OnMenuItemSelected -> onMenuItemSelected(sharedAction)
}
}
.disposeOnDestroy()
if (isFirstCreation()) {
addFragment(
R.id.container,
KnownUsersFragment::class.java,
KnownUsersFragmentArgs(
title = getString(R.string.invite_users_to_room_title),
menuResId = R.menu.vector_invite_users_to_room,
excludedUserIds = viewModel.getUserIdsOfRoomMembers()
)
)
}
viewModel.observeViewEvents { renderInviteEvents(it) }
}
private fun onMenuItemSelected(action: UserDirectorySharedAction.OnMenuItemSelected) {
if (action.itemId == R.id.action_invite_users_to_room_invite) {
viewModel.handle(InviteUsersToRoomAction.InviteSelectedUsers(action.selectedUsers))
}
}
private fun renderInviteEvents(viewEvent: InviteUsersToRoomViewEvents) {
when (viewEvent) {
is InviteUsersToRoomViewEvents.Loading -> renderInviteLoading()
is InviteUsersToRoomViewEvents.Success -> renderInvitationSuccess(viewEvent.successMessage)
is InviteUsersToRoomViewEvents.Failure -> renderInviteFailure(viewEvent.throwable)
}
}
private fun renderInviteLoading() {
updateWaitingView(WaitingViewData(getString(R.string.inviting_users_to_room)))
}
private fun renderInviteFailure(error: Throwable) {
hideWaitingView()
val message = if (error is Failure.ServerError && error.httpCode == HttpURLConnection.HTTP_INTERNAL_ERROR /*500*/) {
// This error happen if the invited userId does not exist.
getString(R.string.invite_users_to_room_failure)
} else {
errorFormatter.toHumanReadable(error)
}
AlertDialog.Builder(this)
.setMessage(message)
.setPositiveButton(R.string.ok, null)
.show()
}
private fun renderInvitationSuccess(successMessage: String) {
toast(successMessage)
finish()
}
companion object {
fun getIntent(context: Context, roomId: String): Intent {
return Intent(context, InviteUsersToRoomActivity::class.java).also {
it.putExtra(MvRx.KEY_ARG, InviteUsersToRoomArgs(roomId))
}
}
}
}

View File

@ -0,0 +1,25 @@
/*
* 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.invite
import im.vector.riotx.core.platform.VectorViewEvents
sealed class InviteUsersToRoomViewEvents : VectorViewEvents {
object Loading : InviteUsersToRoomViewEvents()
data class Failure(val throwable: Throwable) : InviteUsersToRoomViewEvents()
data class Success(val successMessage: String) : InviteUsersToRoomViewEvents()
}

View File

@ -0,0 +1,89 @@
/*
* 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.invite
import com.airbnb.mvrx.ActivityViewModelContext
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.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 io.reactivex.Observable
class InviteUsersToRoomViewModel @AssistedInject constructor(@Assisted
initialState: InviteUsersToRoomViewState,
session: Session,
val stringProvider: StringProvider)
: VectorViewModel<InviteUsersToRoomViewState, InviteUsersToRoomAction, InviteUsersToRoomViewEvents>(initialState) {
private val room = session.getRoom(initialState.roomId)!!
@AssistedInject.Factory
interface Factory {
fun create(initialState: InviteUsersToRoomViewState): InviteUsersToRoomViewModel
}
companion object : MvRxViewModelFactory<InviteUsersToRoomViewModel, InviteUsersToRoomViewState> {
@JvmStatic
override fun create(viewModelContext: ViewModelContext, state: InviteUsersToRoomViewState): InviteUsersToRoomViewModel? {
val activity: InviteUsersToRoomActivity = (viewModelContext as ActivityViewModelContext).activity()
return activity.inviteUsersToRoomViewModelFactory.create(state)
}
}
override fun handle(action: InviteUsersToRoomAction) {
when (action) {
is InviteUsersToRoomAction.InviteSelectedUsers -> inviteUsersToRoom(action.selectedUsers)
}
}
private fun inviteUsersToRoom(selectedUsers: Set<User>) {
_viewEvents.post(InviteUsersToRoomViewEvents.Loading)
Observable.fromIterable(selectedUsers).flatMapCompletable { user ->
room.rx().invite(user.userId, null)
}.subscribe(
{
val successMessage = when (selectedUsers.size) {
1 -> stringProvider.getString(R.string.invitation_sent_to_one_user,
selectedUsers.first().displayName)
2 -> stringProvider.getString(R.string.invitations_sent_to_two_users,
selectedUsers.first().displayName,
selectedUsers.last().displayName)
else -> stringProvider.getQuantityString(R.plurals.invitations_sent_to_one_and_more_users,
selectedUsers.size - 1,
selectedUsers.first().displayName,
selectedUsers.size - 1)
}
_viewEvents.post(InviteUsersToRoomViewEvents.Success(successMessage))
},
{
_viewEvents.post(InviteUsersToRoomViewEvents.Failure(it))
})
.disposeOnClear()
}
fun getUserIdsOfRoomMembers(): Set<String> {
return room.roomSummary()?.otherMemberIds?.toSet() ?: emptySet()
}
}

View File

@ -0,0 +1,29 @@
/*
* 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.invite
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.Uninitialized
data class InviteUsersToRoomViewState(
val roomId: String,
val inviteState: Async<Unit> = Uninitialized
) : MvRxState {
constructor(args: InviteUsersToRoomArgs) : this(roomId = args.roomId)
}

View File

@ -38,7 +38,6 @@ import javax.net.ssl.HttpsURLConnection
abstract class AbstractLoginFragment : VectorBaseFragment(), OnBackPressed {
protected val loginViewModel: LoginViewModel by activityViewModel()
protected lateinit var loginSharedActionViewModel: LoginSharedActionViewModel
private var isResetPasswordStarted = false
@ -57,8 +56,6 @@ abstract class AbstractLoginFragment : VectorBaseFragment(), OnBackPressed {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
loginSharedActionViewModel = activityViewModelProvider.get(LoginSharedActionViewModel::class.java)
loginViewModel.observeViewEvents {
handleLoginViewEvents(it)
}

View File

@ -58,4 +58,6 @@ sealed class LoginAction : VectorViewModelAction {
// For the soft logout case
data class SetupSsoForSessionRecovery(val homeServerUrl: String, val deviceId: String) : LoginAction()
data class PostViewEvent(val viewEvent: LoginViewEvents) : LoginAction()
}

View File

@ -38,6 +38,7 @@ import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.core.extensions.POP_BACK_STACK_EXCLUSIVE
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.ToolbarConfigurable
import im.vector.riotx.core.platform.VectorBaseActivity
import im.vector.riotx.features.home.HomeActivity
@ -54,7 +55,6 @@ import javax.inject.Inject
open class LoginActivity : VectorBaseActivity(), ToolbarConfigurable {
private val loginViewModel: LoginViewModel by viewModel()
private lateinit var loginSharedActionViewModel: LoginSharedActionViewModel
@Inject lateinit var loginViewModelFactory: LoginViewModel.Factory
@ -98,14 +98,6 @@ open class LoginActivity : VectorBaseActivity(), ToolbarConfigurable {
loginViewModel.handle(LoginAction.InitWith(loginConfig))
}
loginSharedActionViewModel = viewModelProvider.get(LoginSharedActionViewModel::class.java)
loginSharedActionViewModel
.observe()
.subscribe {
handleLoginNavigation(it)
}
.disposeOnDestroy()
loginViewModel
.subscribe(this) {
updateWithState(it)
@ -124,65 +116,9 @@ open class LoginActivity : VectorBaseActivity(), ToolbarConfigurable {
addFragment(R.id.loginFragmentContainer, LoginSplashFragment::class.java)
}
private fun handleLoginNavigation(loginNavigation: LoginNavigation) {
// Assigning to dummy make sure we do not forget a case
@Suppress("UNUSED_VARIABLE")
val dummy = when (loginNavigation) {
is LoginNavigation.OpenServerSelection ->
addFragmentToBackstack(R.id.loginFragmentContainer,
LoginServerSelectionFragment::class.java,
option = { ft ->
findViewById<View?>(R.id.loginSplashLogo)?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") }
findViewById<View?>(R.id.loginSplashTitle)?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") }
findViewById<View?>(R.id.loginSplashSubmit)?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") }
// TODO Disabled because it provokes a flickering
// ft.setCustomAnimations(enterAnim, exitAnim, popEnterAnim, popExitAnim)
})
is LoginNavigation.OnServerSelectionDone -> onServerSelectionDone()
is LoginNavigation.OnSignModeSelected -> onSignModeSelected()
is LoginNavigation.OnLoginFlowRetrieved ->
addFragmentToBackstack(R.id.loginFragmentContainer,
LoginSignUpSignInSelectionFragment::class.java,
option = commonOption)
is LoginNavigation.OnWebLoginError -> onWebLoginError(loginNavigation)
is LoginNavigation.OnForgetPasswordClicked ->
addFragmentToBackstack(R.id.loginFragmentContainer,
LoginResetPasswordFragment::class.java,
option = commonOption)
is LoginNavigation.OnResetPasswordSendThreePidDone -> {
supportFragmentManager.popBackStack(FRAGMENT_LOGIN_TAG, POP_BACK_STACK_EXCLUSIVE)
addFragmentToBackstack(R.id.loginFragmentContainer,
LoginResetPasswordMailConfirmationFragment::class.java,
option = commonOption)
}
is LoginNavigation.OnResetPasswordMailConfirmationSuccess -> {
supportFragmentManager.popBackStack(FRAGMENT_LOGIN_TAG, POP_BACK_STACK_EXCLUSIVE)
addFragmentToBackstack(R.id.loginFragmentContainer,
LoginResetPasswordSuccessFragment::class.java,
option = commonOption)
}
is LoginNavigation.OnResetPasswordMailConfirmationSuccessDone -> {
// Go back to the login fragment
supportFragmentManager.popBackStack(FRAGMENT_LOGIN_TAG, POP_BACK_STACK_EXCLUSIVE)
}
is LoginNavigation.OnSendEmailSuccess ->
addFragmentToBackstack(R.id.loginFragmentContainer,
LoginWaitForEmailFragment::class.java,
LoginWaitForEmailFragmentArgument(loginNavigation.email),
tag = FRAGMENT_REGISTRATION_STAGE_TAG,
option = commonOption)
is LoginNavigation.OnSendMsisdnSuccess ->
addFragmentToBackstack(R.id.loginFragmentContainer,
LoginGenericTextInputFormFragment::class.java,
LoginGenericTextInputFormFragmentArgument(TextInputFormFragmentMode.ConfirmMsisdn, true, loginNavigation.msisdn),
tag = FRAGMENT_REGISTRATION_STAGE_TAG,
option = commonOption)
}
}
private fun handleLoginViewEvents(loginViewEvents: LoginViewEvents) {
when (loginViewEvents) {
is LoginViewEvents.RegistrationFlowResult -> {
is LoginViewEvents.RegistrationFlowResult -> {
// Check that all flows are supported by the application
if (loginViewEvents.flowResult.missingStages.any { !it.isSupported() }) {
// Display a popup to propose use web fallback
@ -203,15 +139,64 @@ open class LoginActivity : VectorBaseActivity(), ToolbarConfigurable {
}
}
}
is LoginViewEvents.OutdatedHomeserver ->
is LoginViewEvents.OutdatedHomeserver ->
AlertDialog.Builder(this)
.setTitle(R.string.login_error_outdated_homeserver_title)
.setMessage(R.string.login_error_outdated_homeserver_content)
.setPositiveButton(R.string.ok, null)
.show()
is LoginViewEvents.Failure ->
is LoginViewEvents.Failure ->
// This is handled by the Fragments
Unit
is LoginViewEvents.OpenServerSelection ->
addFragmentToBackstack(R.id.loginFragmentContainer,
LoginServerSelectionFragment::class.java,
option = { ft ->
findViewById<View?>(R.id.loginSplashLogo)?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") }
findViewById<View?>(R.id.loginSplashTitle)?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") }
findViewById<View?>(R.id.loginSplashSubmit)?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") }
// TODO Disabled because it provokes a flickering
// ft.setCustomAnimations(enterAnim, exitAnim, popEnterAnim, popExitAnim)
})
is LoginViewEvents.OnServerSelectionDone -> onServerSelectionDone()
is LoginViewEvents.OnSignModeSelected -> onSignModeSelected()
is LoginViewEvents.OnLoginFlowRetrieved ->
addFragmentToBackstack(R.id.loginFragmentContainer,
LoginSignUpSignInSelectionFragment::class.java,
option = commonOption)
is LoginViewEvents.OnWebLoginError -> onWebLoginError(loginViewEvents)
is LoginViewEvents.OnForgetPasswordClicked ->
addFragmentToBackstack(R.id.loginFragmentContainer,
LoginResetPasswordFragment::class.java,
option = commonOption)
is LoginViewEvents.OnResetPasswordSendThreePidDone -> {
supportFragmentManager.popBackStack(FRAGMENT_LOGIN_TAG, POP_BACK_STACK_EXCLUSIVE)
addFragmentToBackstack(R.id.loginFragmentContainer,
LoginResetPasswordMailConfirmationFragment::class.java,
option = commonOption)
}
is LoginViewEvents.OnResetPasswordMailConfirmationSuccess -> {
supportFragmentManager.popBackStack(FRAGMENT_LOGIN_TAG, POP_BACK_STACK_EXCLUSIVE)
addFragmentToBackstack(R.id.loginFragmentContainer,
LoginResetPasswordSuccessFragment::class.java,
option = commonOption)
}
is LoginViewEvents.OnResetPasswordMailConfirmationSuccessDone -> {
// Go back to the login fragment
supportFragmentManager.popBackStack(FRAGMENT_LOGIN_TAG, POP_BACK_STACK_EXCLUSIVE)
}
is LoginViewEvents.OnSendEmailSuccess ->
addFragmentToBackstack(R.id.loginFragmentContainer,
LoginWaitForEmailFragment::class.java,
LoginWaitForEmailFragmentArgument(loginViewEvents.email),
tag = FRAGMENT_REGISTRATION_STAGE_TAG,
option = commonOption)
is LoginViewEvents.OnSendMsisdnSuccess ->
addFragmentToBackstack(R.id.loginFragmentContainer,
LoginGenericTextInputFormFragment::class.java,
LoginGenericTextInputFormFragmentArgument(TextInputFormFragmentMode.ConfirmMsisdn, true, loginViewEvents.msisdn),
tag = FRAGMENT_REGISTRATION_STAGE_TAG,
option = commonOption)
}
}
@ -230,7 +215,7 @@ open class LoginActivity : VectorBaseActivity(), ToolbarConfigurable {
loginLoading.isVisible = loginViewState.isLoading()
}
private fun onWebLoginError(onWebLoginError: LoginNavigation.OnWebLoginError) {
private fun onWebLoginError(onWebLoginError: LoginViewEvents.OnWebLoginError) {
// Pop the backstack
supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
@ -254,11 +239,11 @@ open class LoginActivity : VectorBaseActivity(), ToolbarConfigurable {
private fun onSignModeSelected() = withState(loginViewModel) { state ->
when (state.signMode) {
SignMode.Unknown -> error("Sign mode has to be set before calling this method")
SignMode.SignUp -> {
SignMode.Unknown -> error("Sign mode has to be set before calling this method")
SignMode.SignUp -> {
// This is managed by the LoginViewEvents
}
SignMode.SignIn -> {
SignMode.SignIn -> {
// It depends on the LoginMode
when (state.loginMode) {
LoginMode.Unknown -> error("Developer error")
@ -272,7 +257,11 @@ open class LoginActivity : VectorBaseActivity(), ToolbarConfigurable {
LoginMode.Unsupported -> onLoginModeNotSupported(state.loginModeSupportedTypes)
}
}
}
SignMode.SignInWithMatrixId -> addFragmentToBackstack(R.id.loginFragmentContainer,
LoginFragment::class.java,
tag = FRAGMENT_LOGIN_TAG,
option = commonOption)
}.exhaustive
}
private fun onRegistrationStageNotSupported() {

View File

@ -31,6 +31,7 @@ import im.vector.matrix.android.api.failure.Failure
import im.vector.matrix.android.api.failure.MatrixError
import im.vector.matrix.android.api.failure.isInvalidPassword
import im.vector.riotx.R
import im.vector.riotx.core.extensions.exhaustive
import im.vector.riotx.core.extensions.hideKeyboard
import im.vector.riotx.core.extensions.showPassword
import im.vector.riotx.core.extensions.toReducedUrl
@ -73,16 +74,17 @@ class LoginFragment @Inject constructor() : AbstractLoginFragment() {
private fun setupAutoFill(state: LoginViewState) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
when (state.signMode) {
SignMode.Unknown -> error("developer error")
SignMode.SignUp -> {
SignMode.Unknown -> error("developer error")
SignMode.SignUp -> {
loginField.setAutofillHints(HintConstants.AUTOFILL_HINT_NEW_USERNAME)
passwordField.setAutofillHints(HintConstants.AUTOFILL_HINT_NEW_PASSWORD)
}
SignMode.SignIn -> {
SignMode.SignIn,
SignMode.SignInWithMatrixId -> {
loginField.setAutofillHints(HintConstants.AUTOFILL_HINT_USERNAME)
passwordField.setAutofillHints(HintConstants.AUTOFILL_HINT_PASSWORD)
}
}
}.exhaustive
}
}
@ -116,35 +118,44 @@ class LoginFragment @Inject constructor() : AbstractLoginFragment() {
}
private fun setupUi(state: LoginViewState) {
val resId = when (state.signMode) {
SignMode.Unknown -> error("developer error")
SignMode.SignUp -> R.string.login_signup_to
SignMode.SignIn -> R.string.login_connect_to
}
loginFieldTil.hint = getString(when (state.signMode) {
SignMode.Unknown -> error("developer error")
SignMode.SignUp -> R.string.login_signup_username_hint
SignMode.SignIn -> R.string.login_signin_username_hint
SignMode.Unknown -> error("developer error")
SignMode.SignUp -> R.string.login_signup_username_hint
SignMode.SignIn -> R.string.login_signin_username_hint
SignMode.SignInWithMatrixId -> R.string.login_signin_matrix_id_hint
})
when (state.serverType) {
ServerType.MatrixOrg -> {
loginServerIcon.isVisible = true
loginServerIcon.setImageResource(R.drawable.ic_logo_matrix_org)
loginTitle.text = getString(resId, state.homeServerUrl.toReducedUrl())
loginNotice.text = getString(R.string.login_server_matrix_org_text)
// Handle direct signin first
if (state.signMode == SignMode.SignInWithMatrixId) {
loginServerIcon.isVisible = false
loginTitle.text = getString(R.string.login_signin_matrix_id_title)
loginNotice.text = getString(R.string.login_signin_matrix_id_notice)
} else {
val resId = when (state.signMode) {
SignMode.Unknown -> error("developer error")
SignMode.SignUp -> R.string.login_signup_to
SignMode.SignIn -> R.string.login_connect_to
SignMode.SignInWithMatrixId -> R.string.login_connect_to
}
ServerType.Modular -> {
loginServerIcon.isVisible = true
loginServerIcon.setImageResource(R.drawable.ic_logo_modular)
loginTitle.text = getString(resId, "Modular")
loginNotice.text = getString(R.string.login_server_modular_text)
}
ServerType.Other -> {
loginServerIcon.isVisible = false
loginTitle.text = getString(resId, state.homeServerUrl.toReducedUrl())
loginNotice.text = getString(R.string.login_server_other_text)
when (state.serverType) {
ServerType.MatrixOrg -> {
loginServerIcon.isVisible = true
loginServerIcon.setImageResource(R.drawable.ic_logo_matrix_org)
loginTitle.text = getString(resId, state.homeServerUrl.toReducedUrl())
loginNotice.text = getString(R.string.login_server_matrix_org_text)
}
ServerType.Modular -> {
loginServerIcon.isVisible = true
loginServerIcon.setImageResource(R.drawable.ic_logo_modular)
loginTitle.text = getString(resId, "Modular")
loginNotice.text = getString(R.string.login_server_modular_text)
}
ServerType.Other -> {
loginServerIcon.isVisible = false
loginTitle.text = getString(resId, state.homeServerUrl.toReducedUrl())
loginNotice.text = getString(R.string.login_server_other_text)
}
}
}
}
@ -153,9 +164,10 @@ class LoginFragment @Inject constructor() : AbstractLoginFragment() {
forgetPasswordButton.isVisible = state.signMode == SignMode.SignIn
loginSubmit.text = getString(when (state.signMode) {
SignMode.Unknown -> error("developer error")
SignMode.SignUp -> R.string.login_signup_submit
SignMode.SignIn -> R.string.login_signin
SignMode.Unknown -> error("developer error")
SignMode.SignUp -> R.string.login_signup_submit
SignMode.SignIn,
SignMode.SignInWithMatrixId -> R.string.login_signin
})
}
@ -178,7 +190,7 @@ class LoginFragment @Inject constructor() : AbstractLoginFragment() {
@OnClick(R.id.forgetPasswordButton)
fun forgetPasswordClicked() {
loginSharedActionViewModel.post(LoginNavigation.OnForgetPasswordClicked)
loginViewModel.handle(LoginAction.PostViewEvent(LoginViewEvents.OnForgetPasswordClicked))
}
private fun setupPasswordReveal() {

View File

@ -217,7 +217,7 @@ class LoginGenericTextInputFormFragment @Inject constructor() : AbstractLoginFra
TextInputFormFragmentMode.SetEmail -> {
if (throwable.is401()) {
// This is normal use case, we go to the mail waiting screen
loginSharedActionViewModel.post(LoginNavigation.OnSendEmailSuccess(loginViewModel.currentThreePid ?: ""))
loginViewModel.handle(LoginAction.PostViewEvent(LoginViewEvents.OnSendEmailSuccess(loginViewModel.currentThreePid ?: "")))
} else {
loginGenericTextInputFormTil.error = errorFormatter.toHumanReadable(throwable)
}
@ -225,7 +225,7 @@ class LoginGenericTextInputFormFragment @Inject constructor() : AbstractLoginFra
TextInputFormFragmentMode.SetMsisdn -> {
if (throwable.is401()) {
// This is normal use case, we go to the enter code screen
loginSharedActionViewModel.post(LoginNavigation.OnSendMsisdnSuccess(loginViewModel.currentThreePid ?: ""))
loginViewModel.handle(LoginAction.PostViewEvent(LoginViewEvents.OnSendMsisdnSuccess(loginViewModel.currentThreePid ?: "")))
} else {
loginGenericTextInputFormTil.error = errorFormatter.toHumanReadable(throwable)
}

View File

@ -1,36 +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.riotx.features.login
import im.vector.riotx.core.platform.VectorSharedAction
// Supported navigation actions for LoginActivity
sealed class LoginNavigation : VectorSharedAction {
object OpenServerSelection : LoginNavigation()
object OnServerSelectionDone : LoginNavigation()
object OnLoginFlowRetrieved : LoginNavigation()
object OnSignModeSelected : LoginNavigation()
object OnForgetPasswordClicked : LoginNavigation()
object OnResetPasswordSendThreePidDone : LoginNavigation()
object OnResetPasswordMailConfirmationSuccess : LoginNavigation()
object OnResetPasswordMailConfirmationSuccessDone : LoginNavigation()
data class OnSendEmailSuccess(val email: String) : LoginNavigation()
data class OnSendMsisdnSuccess(val msisdn: String) : LoginNavigation()
data class OnWebLoginError(val errorCode: Int, val description: String, val failingUrl: String) : LoginNavigation()
}

View File

@ -149,7 +149,7 @@ class LoginResetPasswordFragment @Inject constructor() : AbstractLoginFragment()
resetPasswordEmailTil.error = errorFormatter.toHumanReadable(state.asyncResetPassword.error)
}
is Success -> {
loginSharedActionViewModel.post(LoginNavigation.OnResetPasswordSendThreePidDone)
Unit
}
}
}

View File

@ -64,7 +64,7 @@ class LoginResetPasswordMailConfirmationFragment @Inject constructor() : Abstrac
.show()
}
is Success -> {
loginSharedActionViewModel.post(LoginNavigation.OnResetPasswordMailConfirmationSuccess)
Unit
}
}
}

View File

@ -29,7 +29,7 @@ class LoginResetPasswordSuccessFragment @Inject constructor() : AbstractLoginFra
@OnClick(R.id.resetPasswordSuccessSubmit)
fun submit() {
loginSharedActionViewModel.post(LoginNavigation.OnResetPasswordMailConfirmationSuccessDone)
loginViewModel.handle(LoginAction.PostViewEvent(LoginViewEvents.OnResetPasswordMailConfirmationSuccessDone))
}
override fun resetViewModel() {

View File

@ -95,10 +95,15 @@ class LoginServerSelectionFragment @Inject constructor() : AbstractLoginFragment
// Request login flow here
loginViewModel.handle(LoginAction.UpdateHomeServer(getString(R.string.matrix_org_server_url)))
} else {
loginSharedActionViewModel.post(LoginNavigation.OnServerSelectionDone)
loginViewModel.handle(LoginAction.PostViewEvent(LoginViewEvents.OnServerSelectionDone))
}
}
@OnClick(R.id.loginServerIKnowMyIdSubmit)
fun loginWithMatrixId() {
loginViewModel.handle(LoginAction.UpdateSignMode(SignMode.SignInWithMatrixId))
}
override fun resetViewModel() {
loginViewModel.handle(LoginAction.ResetHomeServerType)
}
@ -108,7 +113,7 @@ class LoginServerSelectionFragment @Inject constructor() : AbstractLoginFragment
if (state.loginMode != LoginMode.Unknown) {
// LoginFlow for matrix.org has been retrieved
loginSharedActionViewModel.post(LoginNavigation.OnLoginFlowRetrieved)
loginViewModel.handle(LoginAction.PostViewEvent(LoginViewEvents.OnLoginFlowRetrieved))
}
}
}

View File

@ -126,7 +126,7 @@ class LoginServerUrlFormFragment @Inject constructor() : AbstractLoginFragment()
if (state.loginMode != LoginMode.Unknown) {
// The home server url is valid
loginSharedActionViewModel.post(LoginNavigation.OnLoginFlowRetrieved)
loginViewModel.handle(LoginAction.PostViewEvent(LoginViewEvents.OnLoginFlowRetrieved))
}
}
}

View File

@ -78,7 +78,6 @@ class LoginSignUpSignInSelectionFragment @Inject constructor() : AbstractLoginFr
@OnClick(R.id.loginSignupSigninSignIn)
fun signIn() {
loginViewModel.handle(LoginAction.UpdateSignMode(SignMode.SignIn))
loginSharedActionViewModel.post(LoginNavigation.OnSignModeSelected)
}
override fun resetViewModel() {

View File

@ -29,7 +29,7 @@ class LoginSplashFragment @Inject constructor() : AbstractLoginFragment() {
@OnClick(R.id.loginSplashSubmit)
fun getStarted() {
loginSharedActionViewModel.post(LoginNavigation.OpenServerSelection)
loginViewModel.handle(LoginAction.PostViewEvent(LoginViewEvents.OpenServerSelection))
}
override fun resetViewModel() {

View File

@ -23,10 +23,26 @@ import im.vector.riotx.core.platform.VectorViewEvents
/**
* Transient events for Login
*/
sealed class LoginViewEvents: VectorViewEvents {
sealed class LoginViewEvents : VectorViewEvents {
data class Loading(val message: CharSequence? = null) : LoginViewEvents()
data class Failure(val throwable: Throwable) : LoginViewEvents()
data class RegistrationFlowResult(val flowResult: FlowResult, val isRegistrationStarted: Boolean) : LoginViewEvents()
object OutdatedHomeserver : LoginViewEvents()
// Navigation event
object OpenServerSelection : LoginViewEvents()
object OnServerSelectionDone : LoginViewEvents()
object OnLoginFlowRetrieved : LoginViewEvents()
object OnSignModeSelected : LoginViewEvents()
object OnForgetPasswordClicked : LoginViewEvents()
object OnResetPasswordSendThreePidDone : LoginViewEvents()
object OnResetPasswordMailConfirmationSuccess : LoginViewEvents()
object OnResetPasswordMailConfirmationSuccessDone : LoginViewEvents()
data class OnSendEmailSuccess(val email: String) : LoginViewEvents()
data class OnSendMsisdnSuccess(val msisdn: String) : LoginViewEvents()
data class OnWebLoginError(val errorCode: Int, val description: String, val failingUrl: String) : LoginViewEvents()
}

View File

@ -17,6 +17,7 @@
package im.vector.riotx.features.login
import android.content.Context
import android.net.Uri
import androidx.fragment.app.FragmentActivity
import com.airbnb.mvrx.ActivityViewModelContext
import com.airbnb.mvrx.Fail
@ -29,19 +30,24 @@ 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.auth.AuthenticationService
import im.vector.matrix.android.api.auth.data.HomeServerConnectionConfig
import im.vector.matrix.android.api.auth.data.LoginFlowResult
import im.vector.matrix.android.api.auth.login.LoginWizard
import im.vector.matrix.android.api.auth.registration.FlowResult
import im.vector.matrix.android.api.auth.registration.RegistrationResult
import im.vector.matrix.android.api.auth.registration.RegistrationWizard
import im.vector.matrix.android.api.auth.registration.Stage
import im.vector.matrix.android.api.auth.wellknown.WellknownResult
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.util.Cancelable
import im.vector.matrix.android.internal.auth.data.LoginFlowTypes
import im.vector.matrix.android.internal.crypto.model.rest.UserPasswordAuth
import im.vector.riotx.R
import im.vector.riotx.core.di.ActiveSessionHolder
import im.vector.riotx.core.extensions.configureAndStart
import im.vector.riotx.core.extensions.exhaustive
import im.vector.riotx.core.platform.VectorViewModel
import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.features.notifications.PushRuleTriggerListener
import im.vector.riotx.features.session.SessionListener
import im.vector.riotx.features.signout.soft.SoftLogoutActivity
@ -51,14 +57,16 @@ import java.util.concurrent.CancellationException
/**
*
*/
class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginViewState,
private val applicationContext: Context,
private val authenticationService: AuthenticationService,
private val activeSessionHolder: ActiveSessionHolder,
private val pushRuleTriggerListener: PushRuleTriggerListener,
private val homeServerConnectionConfigFactory: HomeServerConnectionConfigFactory,
private val sessionListener: SessionListener,
private val reAuthHelper: ReAuthHelper)
class LoginViewModel @AssistedInject constructor(
@Assisted initialState: LoginViewState,
private val applicationContext: Context,
private val authenticationService: AuthenticationService,
private val activeSessionHolder: ActiveSessionHolder,
private val pushRuleTriggerListener: PushRuleTriggerListener,
private val homeServerConnectionConfigFactory: HomeServerConnectionConfigFactory,
private val sessionListener: SessionListener,
private val reAuthHelper: ReAuthHelper,
private val stringProvider: StringProvider)
: VectorViewModel<LoginViewState, LoginAction, LoginViewEvents>(initialState) {
@AssistedInject.Factory
@ -108,7 +116,8 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi
is LoginAction.RegisterAction -> handleRegisterAction(action)
is LoginAction.ResetAction -> handleResetAction(action)
is LoginAction.SetupSsoForSessionRecovery -> handleSetupSsoForSessionRecovery(action)
}
is LoginAction.PostViewEvent -> _viewEvents.post(action.viewEvent)
}.exhaustive
}
private fun handleSetupSsoForSessionRecovery(action: LoginAction.SetupSsoForSessionRecovery) {
@ -320,11 +329,12 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi
)
}
if (action.signMode == SignMode.SignUp) {
startRegistrationFlow()
} else if (action.signMode == SignMode.SignIn) {
startAuthenticationFlow()
}
when (action.signMode) {
SignMode.SignUp -> startRegistrationFlow()
SignMode.SignIn -> startAuthenticationFlow()
SignMode.SignInWithMatrixId -> _viewEvents.post(LoginViewEvents.OnSignModeSelected)
SignMode.Unknown -> Unit
}.exhaustive
}
private fun handleUpdateServerType(action: LoginAction.UpdateServerType) {
@ -365,6 +375,8 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi
resetPasswordEmail = action.email
)
}
_viewEvents.post(LoginViewEvents.OnResetPasswordSendThreePidDone)
}
override fun onFailure(failure: Throwable) {
@ -405,6 +417,8 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi
resetPasswordEmail = null
)
}
_viewEvents.post(LoginViewEvents.OnResetPasswordMailConfirmationSuccess)
}
override fun onFailure(failure: Throwable) {
@ -421,10 +435,78 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi
private fun handleLoginOrRegister(action: LoginAction.LoginOrRegister) = withState { state ->
when (state.signMode) {
SignMode.SignIn -> handleLogin(action)
SignMode.SignUp -> handleRegisterWith(action)
else -> error("Developer error, invalid sign mode")
SignMode.Unknown -> error("Developer error, invalid sign mode")
SignMode.SignIn -> handleLogin(action)
SignMode.SignUp -> handleRegisterWith(action)
SignMode.SignInWithMatrixId -> handleDirectLogin(action)
}.exhaustive
}
private fun handleDirectLogin(action: LoginAction.LoginOrRegister) {
setState {
copy(
asyncLoginAction = Loading()
)
}
authenticationService.getWellKnownData(action.username, object : MatrixCallback<WellknownResult> {
override fun onSuccess(data: WellknownResult) {
when (data) {
is WellknownResult.Prompt ->
onWellknownSuccess(action, data)
is WellknownResult.InvalidMatrixId -> {
setState {
copy(
asyncLoginAction = Uninitialized
)
}
_viewEvents.post(LoginViewEvents.Failure(Exception(stringProvider.getString(R.string.login_signin_matrix_id_error_invalid_matrix_id))))
}
else -> {
setState {
copy(
asyncLoginAction = Uninitialized
)
}
_viewEvents.post(LoginViewEvents.Failure(Exception(stringProvider.getString(R.string.autodiscover_well_known_error))))
}
}.exhaustive
}
override fun onFailure(failure: Throwable) {
setState {
copy(
asyncLoginAction = Fail(failure)
)
}
}
})
}
private fun onWellknownSuccess(action: LoginAction.LoginOrRegister, wellKnownPrompt: WellknownResult.Prompt) {
val homeServerConnectionConfig = HomeServerConnectionConfig(
homeServerUri = Uri.parse(wellKnownPrompt.homeServerUrl),
identityServerUri = wellKnownPrompt.identityServerUrl?.let { Uri.parse(it) }
)
authenticationService.directAuthentication(
homeServerConnectionConfig,
action.username,
action.password,
action.initialDeviceName,
object : MatrixCallback<Session> {
override fun onSuccess(data: Session) {
onSessionCreated(data)
}
override fun onFailure(failure: Throwable) {
setState {
copy(
asyncLoginAction = Fail(failure)
)
}
}
})
}
private fun handleLogin(action: LoginAction.LoginOrRegister) {
@ -477,6 +559,8 @@ class LoginViewModel @AssistedInject constructor(@Assisted initialState: LoginVi
private fun startAuthenticationFlow() {
// Ensure Wizard is ready
loginWizard
_viewEvents.post(LoginViewEvents.OnSignModeSelected)
}
private fun onFlowResponse(flowResult: FlowResult) {

View File

@ -173,7 +173,7 @@ class LoginWebFragment @Inject constructor(
override fun onReceivedError(view: WebView, errorCode: Int, description: String, failingUrl: String) {
super.onReceivedError(view, errorCode, description, failingUrl)
loginSharedActionViewModel.post(LoginNavigation.OnWebLoginError(errorCode, description, failingUrl))
loginViewModel.handle(LoginAction.PostViewEvent(LoginViewEvents.OnWebLoginError(errorCode, description, failingUrl)))
}
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {

View File

@ -21,5 +21,7 @@ enum class SignMode {
// Account creation
SignUp,
// Login
SignIn
SignIn,
// Login directly with matrix Id
SignInWithMatrixId
}

View File

@ -41,6 +41,7 @@ import im.vector.riotx.features.debug.DebugMenuActivity
import im.vector.riotx.features.home.room.detail.RoomDetailActivity
import im.vector.riotx.features.home.room.detail.RoomDetailArgs
import im.vector.riotx.features.home.room.filtered.FilteredRoomsActivity
import im.vector.riotx.features.invite.InviteUsersToRoomActivity
import im.vector.riotx.features.media.BigImageViewerActivity
import im.vector.riotx.features.roomdirectory.RoomDirectoryActivity
import im.vector.riotx.features.roomdirectory.createroom.CreateRoomActivity
@ -163,6 +164,11 @@ class DefaultNavigator @Inject constructor(
context.startActivity(intent)
}
override fun openInviteUsersToRoom(context: Context, roomId: String) {
val intent = InviteUsersToRoomActivity.getIntent(context, roomId)
context.startActivity(intent)
}
override fun openRoomsFiltering(context: Context) {
val intent = FilteredRoomsActivity.newIntent(context)
context.startActivity(intent)

View File

@ -46,6 +46,8 @@ interface Navigator {
fun openCreateDirectRoom(context: Context)
fun openInviteUsersToRoom(context: Context, roomId: String)
fun openRoomDirectory(context: Context, initialFilter: String = "")
fun openRoomsFiltering(context: Context)

View File

@ -348,12 +348,19 @@ class NotificationDrawerManager @Inject constructor(private val context: Context
globalLastMessageTimestamp = lastMessageTimestamp
}
val tickerText = if (roomEventGroupInfo.isDirect) {
stringProvider.getString(R.string.notification_ticker_text_dm, events.last().senderName, events.last().description)
} else {
stringProvider.getString(R.string.notification_ticker_text_group, roomName, events.last().senderName, events.last().description)
}
val notification = notificationUtils.buildMessagesListNotification(
style,
roomEventGroupInfo,
largeBitmap,
lastMessageTimestamp,
myUserDisplayName)
myUserDisplayName,
tickerText)
// is there an id for this room?
notificationUtils.showNotificationMessage(roomId, ROOM_MESSAGES_NOTIFICATION_ID, notification)

View File

@ -381,7 +381,8 @@ class NotificationUtils @Inject constructor(private val context: Context,
roomInfo: RoomEventGroupInfo,
largeIcon: Bitmap?,
lastMessageTimestamp: Long,
senderDisplayNameForReplyCompat: String?): Notification {
senderDisplayNameForReplyCompat: String?,
tickerText: String): Notification {
val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color)
// Build the pending intent for when the notification is clicked
val openRoomIntent = buildOpenRoomIntent(roomInfo.roomId)
@ -478,6 +479,7 @@ class NotificationUtils @Inject constructor(private val context: Context,
System.currentTimeMillis().toInt(), intent, PendingIntent.FLAG_UPDATE_CURRENT)
setDeleteIntent(pendingIntent)
}
.setTicker(tickerText)
.build()
}

View File

@ -17,6 +17,7 @@
package im.vector.riotx.features.roomprofile.members
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import com.airbnb.mvrx.args
import com.airbnb.mvrx.fragmentViewModel
@ -43,6 +44,18 @@ class RoomMemberListFragment @Inject constructor(
override fun getLayoutResId() = R.layout.fragment_room_setting_generic
override fun getMenuRes() = R.menu.menu_room_member_list
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.menu_room_member_list_add_member -> {
navigator.openInviteUsersToRoom(requireContext(), roomProfileArgs.roomId)
return true
}
}
return super.onOptionsItemSelected(item)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
roomMemberListController.callback = this

View File

@ -30,7 +30,7 @@ import im.vector.riotx.core.extensions.hideKeyboard
import im.vector.riotx.features.login.AbstractLoginFragment
import im.vector.riotx.features.login.LoginAction
import im.vector.riotx.features.login.LoginMode
import im.vector.riotx.features.login.LoginNavigation
import im.vector.riotx.features.login.LoginViewEvents
import kotlinx.android.synthetic.main.fragment_generic_recycler.*
import javax.inject.Inject
@ -94,7 +94,7 @@ class SoftLogoutFragment @Inject constructor(
}
override fun signinFallbackSubmit() {
loginSharedActionViewModel.post(LoginNavigation.OnSignModeSelected)
loginViewModel.handle(LoginAction.PostViewEvent(LoginViewEvents.OnSignModeSelected))
}
override fun clearData() {
@ -124,7 +124,7 @@ class SoftLogoutFragment @Inject constructor(
}
override fun forgetPasswordClicked() {
loginSharedActionViewModel.post(LoginNavigation.OnForgetPasswordClicked)
loginViewModel.handle(LoginAction.PostViewEvent(LoginViewEvents.OnForgetPasswordClicked))
}
override fun revealPasswordClicked() {

View File

@ -1,22 +1,20 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* * 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.
* 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.createdirect
package im.vector.riotx.features.userdirectory
import com.airbnb.epoxy.EpoxyController
import com.airbnb.mvrx.Fail
@ -41,7 +39,7 @@ class DirectoryUsersController @Inject constructor(private val session: Session,
private val stringProvider: StringProvider,
private val errorFormatter: ErrorFormatter) : EpoxyController() {
private var state: CreateDirectRoomViewState? = null
private var state: UserDirectoryViewState? = null
var callback: Callback? = null
@ -49,7 +47,7 @@ class DirectoryUsersController @Inject constructor(private val session: Session,
requestModelBuild()
}
fun setData(state: CreateDirectRoomViewState) {
fun setData(state: UserDirectoryViewState) {
this.state = state
requestModelBuild()
}
@ -110,7 +108,7 @@ class DirectoryUsersController @Inject constructor(private val session: Session,
continue
}
val isSelected = selectedUsers.contains(user.userId)
createDirectRoomUserItem {
userDirectoryUserItem {
id(user.userId)
selected(isSelected)
matrixItem(user.toMatrixItem())

View File

@ -1,11 +1,11 @@
/*
* 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.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
* 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,
@ -14,7 +14,7 @@
* limitations under the License.
*/
package im.vector.riotx.features.createdirect
package im.vector.riotx.features.userdirectory
import com.airbnb.epoxy.EpoxyModel
import com.airbnb.epoxy.paging.PagedListEpoxyController
@ -49,7 +49,7 @@ class KnownUsersController @Inject constructor(private val session: Session,
requestModelBuild()
}
fun setData(state: CreateDirectRoomViewState) {
fun setData(state: UserDirectoryViewState) {
this.isFiltering = !state.filterKnownUsersValue.isEmpty()
val newSelection = state.selectedUsers.map { it.userId }
this.users = state.knownUsers
@ -65,7 +65,7 @@ class KnownUsersController @Inject constructor(private val session: Session,
EmptyItem_().id(currentPosition)
} else {
val isSelected = selectedUsers.contains(item.userId)
CreateDirectRoomUserItem_()
UserDirectoryUserItem_()
.id(item.userId)
.selected(isSelected)
.matrixItem(item.toMatrixItem())
@ -84,13 +84,13 @@ class KnownUsersController @Inject constructor(private val session: Session,
} else {
var lastFirstLetter: String? = null
for (model in models) {
if (model is CreateDirectRoomUserItem) {
if (model is UserDirectoryUserItem) {
if (model.matrixItem.id == session.myUserId) continue
val currentFirstLetter = model.matrixItem.firstLetterOfDisplayName()
val showLetter = !isFiltering && currentFirstLetter.isNotEmpty() && lastFirstLetter != currentFirstLetter
lastFirstLetter = currentFirstLetter
CreateDirectRoomLetterHeaderItem_()
UserDirectoryLetterHeaderItem_()
.id(currentFirstLetter)
.letter(currentFirstLetter)
.addIf(showLetter, this)

View File

@ -1,29 +1,29 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* * 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.
* 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.createdirect
package im.vector.riotx.features.userdirectory
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.ScrollView
import androidx.core.view.forEach
import com.airbnb.mvrx.activityViewModel
import com.airbnb.mvrx.args
import com.airbnb.mvrx.withState
import com.google.android.material.chip.Chip
import com.jakewharton.rxbinding3.widget.textChanges
@ -35,30 +35,36 @@ import im.vector.riotx.core.extensions.hideKeyboard
import im.vector.riotx.core.extensions.setupAsSearch
import im.vector.riotx.core.platform.VectorBaseFragment
import im.vector.riotx.core.utils.DimensionConverter
import kotlinx.android.synthetic.main.fragment_create_direct_room.*
import kotlinx.android.synthetic.main.fragment_known_users.*
import javax.inject.Inject
class CreateDirectRoomKnownUsersFragment @Inject constructor(
class KnownUsersFragment @Inject constructor(
val userDirectoryViewModelFactory: UserDirectoryViewModel.Factory,
private val knownUsersController: KnownUsersController,
private val dimensionConverter: DimensionConverter
) : VectorBaseFragment(), KnownUsersController.Callback {
override fun getLayoutResId() = R.layout.fragment_create_direct_room
private val args: KnownUsersFragmentArgs by args()
override fun getMenuRes() = R.menu.vector_create_direct_room
override fun getLayoutResId() = R.layout.fragment_known_users
private val viewModel: CreateDirectRoomViewModel by activityViewModel()
private lateinit var sharedActionViewModel: CreateDirectRoomSharedActionViewModel
override fun getMenuRes() = args.menuResId
private val viewModel: UserDirectoryViewModel by activityViewModel()
private lateinit var sharedActionViewModel: UserDirectorySharedActionViewModel
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
sharedActionViewModel = activityViewModelProvider.get(CreateDirectRoomSharedActionViewModel::class.java)
vectorBaseActivity.setSupportActionBar(createDirectRoomToolbar)
sharedActionViewModel = activityViewModelProvider.get(UserDirectorySharedActionViewModel::class.java)
knownUsersTitle.text = args.title
vectorBaseActivity.setSupportActionBar(knownUsersToolbar)
setupRecyclerView()
setupFilterView()
setupAddByMatrixIdView()
setupCloseView()
viewModel.selectSubscribe(this, CreateDirectRoomViewState::selectedUsers) {
viewModel.selectSubscribe(this, UserDirectoryViewState::selectedUsers) {
renderSelectedUsers(it)
}
}
@ -71,27 +77,22 @@ class CreateDirectRoomKnownUsersFragment @Inject constructor(
override fun onPrepareOptionsMenu(menu: Menu) {
withState(viewModel) {
val createMenuItem = menu.findItem(R.id.action_create_direct_room)
val showMenuItem = it.selectedUsers.isNotEmpty()
createMenuItem.setVisible(showMenuItem)
menu.forEach { menuItem ->
menuItem.isVisible = showMenuItem
}
}
super.onPrepareOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_create_direct_room -> {
viewModel.handle(CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers)
true
}
else ->
super.onOptionsItemSelected(item)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = withState(viewModel) {
sharedActionViewModel.post(UserDirectorySharedAction.OnMenuItemSelected(item.itemId, it.selectedUsers))
return@withState true
}
private fun setupAddByMatrixIdView() {
addByMatrixId.setOnClickListener {
sharedActionViewModel.post(CreateDirectRoomSharedAction.OpenUsersDirectory)
sharedActionViewModel.post(UserDirectorySharedAction.OpenUsersDirectory)
}
}
@ -102,26 +103,26 @@ class CreateDirectRoomKnownUsersFragment @Inject constructor(
}
private fun setupFilterView() {
createDirectRoomFilter
knownUsersFilter
.textChanges()
.startWith(createDirectRoomFilter.text)
.startWith(knownUsersFilter.text)
.subscribe { text ->
val filterValue = text.trim()
val action = if (filterValue.isBlank()) {
CreateDirectRoomAction.ClearFilterKnownUsers
UserDirectoryAction.ClearFilterKnownUsers
} else {
CreateDirectRoomAction.FilterKnownUsers(filterValue.toString())
UserDirectoryAction.FilterKnownUsers(filterValue.toString())
}
viewModel.handle(action)
}
.disposeOnDestroyView()
createDirectRoomFilter.setupAsSearch()
createDirectRoomFilter.requestFocus()
knownUsersFilter.setupAsSearch()
knownUsersFilter.requestFocus()
}
private fun setupCloseView() {
createDirectRoomClose.setOnClickListener {
knownUsersClose.setOnClickListener {
requireActivity().finish()
}
}
@ -157,12 +158,12 @@ class CreateDirectRoomKnownUsersFragment @Inject constructor(
chip.isCloseIconVisible = true
chipGroup.addView(chip)
chip.setOnCloseIconClickListener {
viewModel.handle(CreateDirectRoomAction.RemoveSelectedUser(user))
viewModel.handle(UserDirectoryAction.RemoveSelectedUser(user))
}
}
override fun onItemClick(user: User) {
view?.hideKeyboard()
viewModel.handle(CreateDirectRoomAction.SelectUser(user))
viewModel.handle(UserDirectoryAction.SelectUser(user))
}
}

View File

@ -0,0 +1,27 @@
/*
* 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 android.os.Parcelable
import kotlinx.android.parcel.Parcelize
@Parcelize
data class KnownUsersFragmentArgs(
val title: String,
val menuResId: Int,
val excludedUserIds: Set<String>? = null
) : Parcelable

View File

@ -0,0 +1,28 @@
/*
* 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.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()
}

View File

@ -1,11 +1,11 @@
/*
* 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.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
* 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,
@ -14,7 +14,7 @@
* limitations under the License.
*/
package im.vector.riotx.features.createdirect
package im.vector.riotx.features.userdirectory
import android.os.Bundle
import android.view.View
@ -29,22 +29,22 @@ import im.vector.riotx.core.extensions.hideKeyboard
import im.vector.riotx.core.extensions.setupAsSearch
import im.vector.riotx.core.extensions.showKeyboard
import im.vector.riotx.core.platform.VectorBaseFragment
import kotlinx.android.synthetic.main.fragment_create_direct_room_directory_users.*
import kotlinx.android.synthetic.main.fragment_create_direct_room_directory_users.recyclerView
import kotlinx.android.synthetic.main.fragment_user_directory.*
import javax.inject.Inject
class CreateDirectRoomDirectoryUsersFragment @Inject constructor(
class UserDirectoryFragment @Inject constructor(
private val directRoomController: DirectoryUsersController
) : VectorBaseFragment(), DirectoryUsersController.Callback {
override fun getLayoutResId() = R.layout.fragment_create_direct_room_directory_users
override fun getLayoutResId() = R.layout.fragment_user_directory
private val viewModel: UserDirectoryViewModel by activityViewModel()
private val viewModel: CreateDirectRoomViewModel by activityViewModel()
private lateinit var sharedActionViewModel: CreateDirectRoomSharedActionViewModel
private lateinit var sharedActionViewModel: UserDirectorySharedActionViewModel
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
sharedActionViewModel = activityViewModelProvider.get(CreateDirectRoomSharedActionViewModel::class.java)
sharedActionViewModel = activityViewModelProvider.get(UserDirectorySharedActionViewModel::class.java)
setupRecyclerView()
setupSearchByMatrixIdView()
setupCloseView()
@ -62,19 +62,19 @@ class CreateDirectRoomDirectoryUsersFragment @Inject constructor(
}
private fun setupSearchByMatrixIdView() {
createDirectRoomSearchById.setupAsSearch(searchIconRes = 0)
createDirectRoomSearchById
userDirectorySearchById.setupAsSearch(searchIconRes = 0)
userDirectorySearchById
.textChanges()
.subscribe {
viewModel.handle(CreateDirectRoomAction.SearchDirectoryUsers(it.toString()))
viewModel.handle(UserDirectoryAction.SearchDirectoryUsers(it.toString()))
}
.disposeOnDestroyView()
createDirectRoomSearchById.showKeyboard(andRequestFocus = true)
userDirectorySearchById.showKeyboard(andRequestFocus = true)
}
private fun setupCloseView() {
createDirectRoomClose.setOnClickListener {
sharedActionViewModel.post(CreateDirectRoomSharedAction.GoBack)
userDirectoryClose.setOnClickListener {
sharedActionViewModel.post(UserDirectorySharedAction.GoBack)
}
}
@ -84,12 +84,12 @@ class CreateDirectRoomDirectoryUsersFragment @Inject constructor(
override fun onItemClick(user: User) {
view?.hideKeyboard()
viewModel.handle(CreateDirectRoomAction.SelectUser(user))
sharedActionViewModel.post(CreateDirectRoomSharedAction.GoBack)
viewModel.handle(UserDirectoryAction.SelectUser(user))
sharedActionViewModel.post(UserDirectorySharedAction.GoBack)
}
override fun retryDirectoryUsersRequest() {
val currentSearch = createDirectRoomSearchById.text.toString()
viewModel.handle(CreateDirectRoomAction.SearchDirectoryUsers(currentSearch))
val currentSearch = userDirectorySearchById.text.toString()
viewModel.handle(UserDirectoryAction.SearchDirectoryUsers(currentSearch))
}
}

View File

@ -1,11 +1,11 @@
/*
* 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.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
* 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,
@ -14,7 +14,7 @@
* limitations under the License.
*/
package im.vector.riotx.features.createdirect
package im.vector.riotx.features.userdirectory
import android.widget.TextView
import com.airbnb.epoxy.EpoxyAttribute
@ -23,8 +23,8 @@ import im.vector.riotx.R
import im.vector.riotx.core.epoxy.VectorEpoxyHolder
import im.vector.riotx.core.epoxy.VectorEpoxyModel
@EpoxyModelClass(layout = R.layout.item_create_direct_room_letter_header)
abstract class CreateDirectRoomLetterHeaderItem : VectorEpoxyModel<CreateDirectRoomLetterHeaderItem.Holder>() {
@EpoxyModelClass(layout = R.layout.item_user_directory_letter_header)
abstract class UserDirectoryLetterHeaderItem : VectorEpoxyModel<UserDirectoryLetterHeaderItem.Holder>() {
@EpoxyAttribute var letter: String = ""
@ -33,6 +33,6 @@ abstract class CreateDirectRoomLetterHeaderItem : VectorEpoxyModel<CreateDirectR
}
class Holder : VectorEpoxyHolder() {
val letterView by bind<TextView>(R.id.createDirectRoomLetterView)
val letterView by bind<TextView>(R.id.userDirectoryLetterView)
}
}

View File

@ -0,0 +1,27 @@
/*
* 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.user.model.User
import im.vector.riotx.core.platform.VectorSharedAction
sealed class UserDirectorySharedAction : VectorSharedAction {
object OpenUsersDirectory : UserDirectorySharedAction()
object Close : UserDirectorySharedAction()
object GoBack : UserDirectorySharedAction()
data class OnMenuItemSelected(val itemId: Int, val selectedUsers: Set<User>) : UserDirectorySharedAction()
}

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,9 +14,9 @@
* limitations under the License.
*/
package im.vector.riotx.features.login
package im.vector.riotx.features.userdirectory
import im.vector.riotx.core.platform.VectorSharedActionViewModel
import javax.inject.Inject
class LoginSharedActionViewModel @Inject constructor() : VectorSharedActionViewModel<LoginNavigation>()
class UserDirectorySharedActionViewModel @Inject constructor() : VectorSharedActionViewModel<UserDirectorySharedAction>()

View File

@ -1,22 +1,20 @@
/*
* Copyright (c) 2020 New Vector Ltd
*
* * 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.
* 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.createdirect
package im.vector.riotx.features.userdirectory
import android.view.View
import android.widget.ImageView
@ -31,8 +29,8 @@ 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_create_direct_room_user)
abstract class CreateDirectRoomUserItem : VectorEpoxyModel<CreateDirectRoomUserItem.Holder>() {
@EpoxyModelClass(layout = R.layout.item_known_user)
abstract class UserDirectoryUserItem : VectorEpoxyModel<UserDirectoryUserItem.Holder>() {
@EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer
@EpoxyAttribute lateinit var matrixItem: MatrixItem
@ -66,9 +64,9 @@ abstract class CreateDirectRoomUserItem : VectorEpoxyModel<CreateDirectRoomUserI
}
class Holder : VectorEpoxyHolder() {
val userIdView by bind<TextView>(R.id.createDirectRoomUserID)
val nameView by bind<TextView>(R.id.createDirectRoomUserName)
val avatarImageView by bind<ImageView>(R.id.createDirectRoomUserAvatar)
val avatarCheckedImageView by bind<ImageView>(R.id.createDirectRoomUserAvatarChecked)
val userIdView by bind<TextView>(R.id.knownUserID)
val nameView by bind<TextView>(R.id.knownUserName)
val avatarImageView by bind<ImageView>(R.id.knownUserAvatar)
val avatarCheckedImageView by bind<ImageView>(R.id.knownUserAvatarChecked)
}
}

View File

@ -1,11 +1,11 @@
/*
* 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.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
* 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,
@ -14,9 +14,11 @@
* limitations under the License.
*/
package im.vector.riotx.features.createdirect
package im.vector.riotx.features.userdirectory
import im.vector.riotx.core.platform.VectorSharedActionViewModel
import javax.inject.Inject
import im.vector.riotx.core.platform.VectorViewEvents
class CreateDirectRoomSharedActionViewModel @Inject constructor() : VectorSharedActionViewModel<CreateDirectRoomSharedAction>()
/**
* Transient events for invite users to room screen
*/
sealed class UserDirectoryViewEvents : VectorViewEvents

View File

@ -0,0 +1,134 @@
/*
* 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 androidx.fragment.app.FragmentActivity
import arrow.core.Option
import com.airbnb.mvrx.ActivityViewModelContext
import com.airbnb.mvrx.FragmentViewModelContext
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import com.jakewharton.rxrelay2.BehaviorRelay
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.util.toMatrixItem
import im.vector.matrix.rx.rx
import im.vector.riotx.core.extensions.toggle
import im.vector.riotx.core.platform.VectorViewModel
import im.vector.riotx.features.createdirect.CreateDirectRoomActivity
import im.vector.riotx.features.invite.InviteUsersToRoomActivity
import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers
import java.util.concurrent.TimeUnit
private typealias KnowUsersFilter = String
private typealias DirectoryUsersSearch = String
class UserDirectoryViewModel @AssistedInject constructor(@Assisted
initialState: UserDirectoryViewState,
private val session: Session)
: VectorViewModel<UserDirectoryViewState, UserDirectoryAction, UserDirectoryViewEvents>(initialState) {
@AssistedInject.Factory
interface Factory {
fun create(initialState: UserDirectoryViewState): UserDirectoryViewModel
}
private val knownUsersFilter = BehaviorRelay.createDefault<Option<KnowUsersFilter>>(Option.empty())
private val directoryUsersSearch = BehaviorRelay.create<DirectoryUsersSearch>()
companion object : MvRxViewModelFactory<UserDirectoryViewModel, UserDirectoryViewState> {
override fun create(viewModelContext: ViewModelContext, state: UserDirectoryViewState): UserDirectoryViewModel? {
return when (viewModelContext) {
is FragmentViewModelContext -> (viewModelContext.fragment() as KnownUsersFragment).userDirectoryViewModelFactory.create(state)
is ActivityViewModelContext -> {
when (viewModelContext.activity<FragmentActivity>()) {
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")
}
}
}
init {
observeKnownUsers()
observeDirectoryUsers()
}
override fun handle(action: UserDirectoryAction) {
when (action) {
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)
}
}
private fun handleRemoveSelectedUser(action: UserDirectoryAction.RemoveSelectedUser) = withState { state ->
val selectedUsers = state.selectedUsers.minus(action.user)
setState { copy(selectedUsers = selectedUsers) }
}
private fun handleSelectUser(action: UserDirectoryAction.SelectUser) = withState { state ->
// Reset the filter asap
directoryUsersSearch.accept("")
val selectedUsers = state.selectedUsers.toggle(action.user)
setState { copy(selectedUsers = selectedUsers) }
}
private fun observeDirectoryUsers() = withState { state ->
directoryUsersSearch
.debounce(300, TimeUnit.MILLISECONDS)
.switchMapSingle { search ->
val stream = if (search.isBlank()) {
Single.just(emptyList())
} else {
session.rx()
.searchUsersDirectory(search, 50, state.excludedUserIds ?: emptySet())
.map { users ->
users.sortedBy { it.toMatrixItem().firstLetterOfDisplayName() }
}
}
stream.toAsync {
copy(directoryUsers = it, directorySearchTerm = search)
}
}
.subscribe()
.disposeOnClear()
}
private fun observeKnownUsers() = withState { state ->
knownUsersFilter
.throttleLast(300, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.switchMap {
session.rx().livePagedUsers(it.orNull(), state.excludedUserIds)
}
.execute { async ->
copy(
knownUsers = async,
filterKnownUsersValue = knownUsersFilter.value ?: Option.empty()
)
}
}
}

View File

@ -0,0 +1,37 @@
/*
* 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 androidx.paging.PagedList
import arrow.core.Option
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.Uninitialized
import im.vector.matrix.android.api.session.user.model.User
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 createAndInviteState: Async<String> = Uninitialized,
val directorySearchTerm: String = "",
val filterKnownUsersValue: Option<String> = Option.empty()
) : MvRxState {
constructor(args: KnownUsersFragmentArgs) : this(excludedUserIds = args.excludedUserIds)
}

View File

@ -0,0 +1,18 @@
<vector android:autoMirrored="true" android:height="24dp"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#00000000"
android:pathData="M16,21V19C16,16.7909 14.2091,15 12,15H5C2.7909,15 1,16.7909 1,19V21"
android:strokeColor="#03B381" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="2"/>
<path android:fillColor="#00000000" android:fillType="evenOdd"
android:pathData="M8.5,11C10.7091,11 12.5,9.2091 12.5,7C12.5,4.7909 10.7091,3 8.5,3C6.2909,3 4.5,4.7909 4.5,7C4.5,9.2091 6.2909,11 8.5,11Z"
android:strokeColor="#03B381" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="2"/>
<path android:fillColor="#00000000" android:pathData="M20,8V14"
android:strokeColor="#03B381" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="2"/>
<path android:fillColor="#00000000" android:pathData="M23,11H17"
android:strokeColor="#03B381" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="2"/>
</vector>

View File

@ -0,0 +1,143 @@
<?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/knownUsersToolbar"
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/knownUsersClose"
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" />
<im.vector.riotx.core.platform.EllipsizingTextView
android:id="@+id/knownUsersTitle"
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/fab_menu_create_chat"
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/knownUsersClose"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.appcompat.widget.Toolbar>
<im.vector.riotx.core.platform.MaxHeightScrollView
android:id="@+id/chipGroupScrollView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/layout_horizontal_margin"
android:layout_marginTop="8dp"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/knownUsersToolbar"
app:maxHeight="64dp">
<com.google.android.material.chip.ChipGroup
android:id="@+id/chipGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:lineSpacing="2dp" />
</im.vector.riotx.core.platform.MaxHeightScrollView>
<EditText
android:id="@+id/knownUsersFilter"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/layout_horizontal_margin"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:background="@null"
android:drawablePadding="8dp"
android:gravity="center_vertical"
android:hint="@string/direct_room_filter_hint"
android:importantForAutofill="no"
android:inputType="text"
android:maxHeight="80dp"
android:paddingTop="16dp"
android:paddingBottom="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/chipGroupScrollView" />
<View
android:id="@+id/knownUsersFilterDivider"
android:layout_width="0dp"
android:layout_height="1dp"
android:background="?attr/vctr_list_divider_color"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/knownUsersFilter" />
<com.google.android.material.button.MaterialButton
android:id="@+id/addByMatrixId"
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/add_by_matrix_id"
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/knownUsersFilterDivider" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
android:fastScrollEnabled="true"
android:overScrollMode="always"
android:scrollbars="vertical"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/addByMatrixId"
tools:listitem="@layout/item_known_user" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -16,7 +16,9 @@
style="@style/LoginFormScrollView"
tools:ignore="MissingConstraints">
<androidx.constraintlayout.widget.ConstraintLayout style="@style/LoginFormContainer">
<androidx.constraintlayout.widget.ConstraintLayout
style="@style/LoginFormContainer"
android:paddingBottom="@dimen/layout_vertical_margin">
<TextView
android:id="@+id/loginServerTitle"
@ -184,11 +186,36 @@
android:layout_marginTop="24dp"
android:text="@string/login_continue"
android:transitionName="loginSubmitTransition"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintBottom_toTopOf="@+id/loginServerIKnowMyIdNotice"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/loginServerChoiceOther" />
<TextView
android:id="@+id/loginServerIKnowMyIdNotice"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:gravity="start"
android:text="@string/login_connect_using_matrix_id_notice"
android:textAppearance="@style/TextAppearance.Vector.Login.Text.Small"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/loginServerSubmit" />
<com.google.android.material.button.MaterialButton
android:id="@+id/loginServerIKnowMyIdSubmit"
style="@style/Style.Vector.Login.Button.Text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/login_connect_using_matrix_id_submit"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/loginServerIKnowMyIdNotice" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>

View File

@ -0,0 +1,109 @@
<?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/userDirectoryToolbar"
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/userDirectoryClose"
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" />
<im.vector.riotx.core.platform.EllipsizingTextView
android:id="@+id/userDirectoryTitle"
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/direct_chats_header"
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/userDirectoryClose"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.appcompat.widget.Toolbar>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/userDirectorySearchByIdContainer"
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/userDirectoryToolbar">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/userDirectorySearchById"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/add_by_matrix_id" />
</com.google.android.material.textfield.TextInputLayout>
<View
android:id="@+id/userDirectoryFilterDivider"
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_marginTop="16dp"
android:background="?attr/vctr_list_divider_color"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/userDirectorySearchByIdContainer" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="16dp"
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/userDirectoryFilterDivider"
tools:listitem="@layout/item_known_user" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,72 @@
<?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:gravity="center_vertical"
android:orientation="horizontal"
android:padding="8dp">
<FrameLayout
android:id="@+id/knownUserAvatarContainer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:id="@+id/knownUserAvatar"
android:layout_width="40dp"
android:layout_height="40dp"
tools:src="@tools:sample/avatars" />
<ImageView
android:id="@+id/knownUserAvatarChecked"
android:layout_width="40dp"
android:layout_height="40dp"
android:scaleType="centerInside"
android:src="@drawable/ic_material_done"
android:tint="@android:color/white"
android:visibility="visible" />
</FrameLayout>
<im.vector.riotx.core.platform.EllipsizingTextView
android:id="@+id/knownUserName"
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_toTopOf="@+id/knownUserID"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toEndOf="@+id/knownUserAvatarContainer"
app:layout_constraintTop_toTopOf="parent"
tools:text="@tools:sample/full_names" />
<im.vector.riotx.core.platform.EllipsizingTextView
android:id="@+id/knownUserID"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?riotx_text_secondary"
android:textSize="15sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="@+id/knownUserName"
app:layout_constraintTop_toBottomOf="@+id/knownUserName"
tools:text="Blabla" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/userDirectoryLetterView"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="8dp"
android:fontFamily="sans-serif-medium"
android:padding="8dp"
android:textColor="?attr/riotx_text_primary"
android:textSize="20sp"
android:textStyle="normal"
tools:text="C" />

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/menu_room_member_list_add_member"
android:title="@string/add_members_to_room"
android:icon="@drawable/ic_invite_users"
app:iconTint="?attr/colorAccent"
app:showAsAction="always" />
</menu>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_invite_users_to_room_invite"
android:title="@string/invite_users_to_room_action_invite"
app:showAsAction="always" />
</menu>

View File

@ -1089,6 +1089,8 @@
<string name="notification_new_invitation">New Invitation</string>
<string name="notification_sender_me">Me</string>
<string name="notification_inline_reply_failed">** Failed to send - please open room</string>
<string name="notification_ticker_text_dm">%1$s: %2$s</string>
<string name="notification_ticker_text_group">%1$s: %2$s %3$s</string>
<!-- historical -->
<string name="historical_placeholder">Search for historical</string>
@ -2369,4 +2371,17 @@ Not all features in Riot are implemented in RiotX yet. Main missing (and coming
<string name="external_link_confirmation_message">The link %1$s is taking you to another site: %2$s.\n\nAre you sure you want to continue?</string>
<string name="create_room_dm_failure">"We couldn't create your DM. Please check the users you want to invite and try again."</string>
</resources>
<string name="add_members_to_room">Add members</string>
<string name="invite_users_to_room_action_invite">INVITE</string>
<string name="inviting_users_to_room">Inviting users…</string>
<string name="invite_users_to_room_title">Invite Users</string>
<string name="invitation_sent_to_one_user">Invitation sent to %1$s</string>
<string name="invitations_sent_to_two_users">Invitations sent to %1$s and %2$s</string>
<plurals name="invitations_sent_to_one_and_more_users">
<item quantity="one">Invitations sent to %1$s and one more</item>
<item quantity="other">Invitations sent to %1$s and %2$d more</item>
</plurals>
<string name="invite_users_to_room_failure">We could not invite users. Please check the users you want to invite and try again.</string>
</resources>

View File

@ -57,4 +57,12 @@
<!-- END Strings added by Others -->
<string name="login_connect_using_matrix_id_notice">Alternatively, if you already have an account and you know your Matrix identifier and your password, you can use this method:</string>
<string name="login_connect_using_matrix_id_submit">Sign in with my Matrix identifier</string>
<string name="login_signin_matrix_id_title">Sign in</string>
<string name="login_signin_matrix_id_notice">Enter your identifier and your password</string>
<string name="login_signin_matrix_id_hint">User identifier</string>
<string name="login_signin_matrix_id_error_invalid_matrix_id">This is not a valid user identifier. Expected format: \'@user:homeserver.org\'</string>
<string name="autodiscover_well_known_error">Unable to find a valid homeserver. Please check your identifier</string>
</resources>