Merge pull request #3739 from vector-im/feature/bca/accept_unbound_3pid_invite

support email invite
This commit is contained in:
Benoit Marty 2021-08-27 19:02:42 +02:00 committed by GitHub
commit 65c8ae3597
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 512 additions and 33 deletions

1
changelog.d/3691.feature Normal file
View file

@ -0,0 +1 @@
Support accept 3pid invite when email is not bound to account

1
changelog.d/3695.feature Normal file
View file

@ -0,0 +1 @@
Update Email invite to be aware of spaces

View file

@ -0,0 +1,60 @@
/*
* Copyright 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk
import org.junit.Assert
import org.junit.FixMethodOrder
import org.junit.Test
import org.junit.runners.MethodSorters
import org.matrix.android.sdk.api.session.permalinks.PermalinkData
import org.matrix.android.sdk.api.session.permalinks.PermalinkParser
@FixMethodOrder(MethodSorters.JVM)
class PermalinkParserTest {
@Test
fun testParseEmailInvite() {
val rawInvite = """
https://app.element.io/#/room/%21MRBNLPtFnMAazZVPMO%3Amatrix.org?email=bob%2Bspace%40example.com&signurl=https%3A%2F%2Fvector.im%2F_matrix%2Fidentity%2Fapi%2Fv1%2Fsign-ed25519%3Ftoken%3DXmOwRZnSFabCRhTywFbJWKXWVNPysOpXIbroMGaUymqkJSvHeVKRsjHajwjCYdBsvGSvHauxbKfJmOxtXldtyLnyBMLKpBQCMzyYggrdapbVIceWZBtmslOQrXLABRoe%26private_key%3DT2gq0c3kJB_8OroXVxl1pBnzHsN7V6Xn4bEBSeW1ep4&room_name=Team2&room_avatar_url=&inviter_name=hiphop5&guest_access_token=&guest_user_id=
""".trimIndent()
.replace("https://app.element.io/#/room/", "https://matrix.to/#/")
val parsedLink = PermalinkParser.parse(rawInvite)
Assert.assertTrue("Should be parsed as email invite but was ${parsedLink::class.java}", parsedLink is PermalinkData.RoomEmailInviteLink)
parsedLink as PermalinkData.RoomEmailInviteLink
Assert.assertEquals("!MRBNLPtFnMAazZVPMO:matrix.org", parsedLink.roomId)
Assert.assertEquals("XmOwRZnSFabCRhTywFbJWKXWVNPysOpXIbroMGaUymqkJSvHeVKRsjHajwjCYdBsvGSvHauxbKfJmOxtXldtyLnyBMLKpBQCMzyYggrdapbVIceWZBtmslOQrXLABRoe", parsedLink.token)
Assert.assertEquals("vector.im", parsedLink.identityServer)
Assert.assertEquals("Team2", parsedLink.roomName)
Assert.assertEquals("hiphop5", parsedLink.inviterName)
}
@Test
fun testParseLinkWIthEvent() {
val rawInvite = "https://matrix.to/#/!OGEhHVWSdvArJzumhm:matrix.org/\$xuvJUVDJnwEeVjPx029rAOZ50difpmU_5gZk_T0jGfc?via=matrix.org&via=libera.chat&via=matrix.example.io"
val parsedLink = PermalinkParser.parse(rawInvite)
Assert.assertTrue("Should be parsed as room link", parsedLink is PermalinkData.RoomLink)
parsedLink as PermalinkData.RoomLink
Assert.assertEquals("!OGEhHVWSdvArJzumhm:matrix.org", parsedLink.roomIdOrAlias)
Assert.assertEquals("\$xuvJUVDJnwEeVjPx029rAOZ50difpmU_5gZk_T0jGfc", parsedLink.eventId)
Assert.assertEquals(3, parsedLink.viaParameters.size)
Assert.assertTrue(parsedLink.viaParameters.contains("matrix.example.io"))
Assert.assertTrue(parsedLink.viaParameters.contains("matrix.org"))
Assert.assertTrue(parsedLink.viaParameters.contains("matrix.example.io"))
}
}

View file

@ -16,6 +16,8 @@
package org.matrix.android.sdk.api.session.identity
import org.matrix.android.sdk.internal.session.identity.model.SignInvitationResult
/**
* Provides access to the identity server configuration and services identity server can provide
*/
@ -121,6 +123,18 @@ interface IdentityService {
*/
suspend fun getShareStatus(threePids: List<ThreePid>): Map<ThreePid, SharedState>
/**
* When one performs a 3pid invite and the third party identifier is unknown, the home server
* will store the invitation in the Identity server and store some information in the room state membership event.
* The email invite will contains the token and secret that can be used to claim the stored invitation
*
* To aid clients who may not be able to perform crypto themselves,
* the identity server offers some crypto functionality to help in accepting invitations.
* This is less secure than the client doing it itself, but may be useful where this isn't possible.
*/
suspend fun sign3pidInvitation(identiyServer: String, token: String, secret: String) : SignInvitationResult
fun addListener(listener: IdentityServiceListener)
fun removeListener(listener: IdentityServiceListener)
}

View file

@ -17,6 +17,8 @@
package org.matrix.android.sdk.api.session.permalinks
import android.net.Uri
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* This sealed class represents all the permalink cases.
@ -31,6 +33,25 @@ sealed class PermalinkData {
val viaParameters: List<String>
) : PermalinkData()
/**
* &room_name=Team2
&room_avatar_url=mxc:
&inviter_name=bob
*/
@Parcelize
data class RoomEmailInviteLink(
val roomId: String,
val email: String,
val signUrl: String,
val roomName: String?,
val roomAvatarUrl: String?,
val inviterName: String?,
val identityServer: String,
val token: String,
val privateKey: String,
val roomType: String?
) : PermalinkData(), Parcelable
data class UserLink(val userId: String) : PermalinkData()
data class GroupLink(val groupId: String) : PermalinkData()

View file

@ -19,6 +19,8 @@ package org.matrix.android.sdk.api.session.permalinks
import android.net.Uri
import android.net.UrlQuerySanitizer
import org.matrix.android.sdk.api.MatrixPatterns
import timber.log.Timber
import java.net.URLDecoder
/**
* This class turns an uri to a [PermalinkData]
@ -35,12 +37,15 @@ object PermalinkParser {
/**
* Turns an uri to a [PermalinkData]
* https://github.com/matrix-org/matrix-doc/blob/master/proposals/1704-matrix.to-permalinks.md
*/
fun parse(uri: Uri): PermalinkData {
if (!uri.toString().startsWith(PermalinkService.MATRIX_TO_URL_BASE)) {
return PermalinkData.FallbackLink(uri)
}
val fragment = uri.fragment
// We can't use uri.fragment as it is decoding to early and it will break the parsing
// of parameters that represents url (like signurl)
val fragment = uri.toString().substringAfter("#") // uri.fragment
if (fragment.isNullOrEmpty()) {
return PermalinkData.FallbackLink(uri)
}
@ -51,6 +56,7 @@ object PermalinkParser {
val params = safeFragment
.split(MatrixPatterns.SEP_REGEX)
.filter { it.isNotEmpty() }
.map { URLDecoder.decode(it, "UTF-8") }
.take(2)
val identifier = params.getOrNull(0)
@ -60,12 +66,7 @@ object PermalinkParser {
MatrixPatterns.isUserId(identifier) -> PermalinkData.UserLink(userId = identifier)
MatrixPatterns.isGroupId(identifier) -> PermalinkData.GroupLink(groupId = identifier)
MatrixPatterns.isRoomId(identifier) -> {
PermalinkData.RoomLink(
roomIdOrAlias = identifier,
isRoomAlias = false,
eventId = extraParameter.takeIf { !it.isNullOrEmpty() && MatrixPatterns.isEventId(it) },
viaParameters = viaQueryParameters
)
handleRoomIdCase(fragment, identifier, uri, extraParameter, viaQueryParameters)
}
MatrixPatterns.isRoomAlias(identifier) -> {
PermalinkData.RoomLink(
@ -79,13 +80,59 @@ object PermalinkParser {
}
}
private fun handleRoomIdCase(fragment: String, identifier: String, uri: Uri, extraParameter: String?, viaQueryParameters: List<String>): PermalinkData {
// Can't rely on built in parsing because it's messing around the signurl
val paramList = safeExtractParams(fragment)
val signUrl = paramList.firstOrNull { it.first == "signurl" }?.second
val email = paramList.firstOrNull { it.first == "email" }?.second
return if (signUrl.isNullOrEmpty().not() && email.isNullOrEmpty().not()) {
try {
val signValidUri = Uri.parse(signUrl)
val identityServerHost = signValidUri.authority ?: throw IllegalArgumentException()
val token = signValidUri.getQueryParameter("token") ?: throw IllegalArgumentException()
val privateKey = signValidUri.getQueryParameter("private_key") ?: throw IllegalArgumentException()
PermalinkData.RoomEmailInviteLink(
roomId = identifier,
email = email!!,
signUrl = signUrl!!,
roomName = paramList.firstOrNull { it.first == "room_name" }?.second,
inviterName = paramList.firstOrNull { it.first == "inviter_name" }?.second,
roomAvatarUrl = paramList.firstOrNull { it.first == "room_avatar_url" }?.second,
roomType = paramList.firstOrNull { it.first == "room_type" }?.second,
identityServer = identityServerHost,
token = token,
privateKey = privateKey
)
} catch (failure: Throwable) {
Timber.i("## Permalink: Failed to parse permalink $signUrl")
PermalinkData.FallbackLink(uri)
}
} else {
PermalinkData.RoomLink(
roomIdOrAlias = identifier,
isRoomAlias = false,
eventId = extraParameter.takeIf { !it.isNullOrEmpty() && MatrixPatterns.isEventId(it) },
viaParameters = viaQueryParameters
)
}
}
private fun safeExtractParams(fragment: String) = fragment.substringAfter("?").split('&').mapNotNull {
val splitNameValue = it.split("=")
if (splitNameValue.size == 2) {
Pair(splitNameValue[0], URLDecoder.decode(splitNameValue[1], "UTF-8"))
} else null
}
private fun String.getViaParameters(): List<String> {
return UrlQuerySanitizer(this)
.parameterList
.filter {
it.mParameter == "via"
}.map {
it.mValue
it.mValue.let {
URLDecoder.decode(it, "UTF-8")
}
}
}
}

View file

@ -27,6 +27,7 @@ import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams
import org.matrix.android.sdk.api.session.room.peeking.PeekResult
import org.matrix.android.sdk.api.session.room.summary.RoomAggregateNotificationCount
import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.internal.session.identity.model.SignInvitationResult
import org.matrix.android.sdk.internal.session.room.alias.RoomAliasDescription
/**
@ -63,6 +64,18 @@ interface RoomService {
reason: String? = null,
viaServers: List<String> = emptyList())
/**
* @param roomId the roomId of the room to join
* @param reason optional reason for joining the room
* @param thirdPartySigned A signature of an m.third_party_invite token to prove that this user owns a third party identity
* which has been invited to the room.
*/
suspend fun joinRoom(
roomId: String,
reason: String? = null,
thirdPartySigned: SignInvitationResult
)
/**
* Get a room from a roomId
* @param roomId the roomId to look for.

View file

@ -30,6 +30,7 @@ internal object NetworkConstants {
// Identity server
const val URI_IDENTITY_PREFIX_PATH = "_matrix/identity/v2"
const val URI_IDENTITY_PATH_V2 = "$URI_IDENTITY_PREFIX_PATH/"
const val URI_IDENTITY_PATH_V1 = "_matrix/identity/api/v1/"
// Push Gateway
const val URI_PUSH_GATEWAY_PREFIX_PATH = "_matrix/push/v1/"

View file

@ -52,6 +52,7 @@ import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.internal.session.identity.model.SignInvitationResult
import timber.log.Timber
import javax.inject.Inject
import javax.net.ssl.HttpsURLConnection
@ -79,6 +80,7 @@ internal class DefaultIdentityService @Inject constructor(
private val identityApiProvider: IdentityApiProvider,
private val accountDataDataSource: UserAccountDataDataSource,
private val homeServerCapabilitiesService: HomeServerCapabilitiesService,
private val sign3pidInvitationTask: DefaultSign3pidInvitationTask,
private val sessionParams: SessionParams
) : IdentityService, SessionLifecycleObserver {
@ -290,6 +292,14 @@ internal class DefaultIdentityService @Inject constructor(
return token.token
}
override suspend fun sign3pidInvitation(identiyServer: String, token: String, secret: String): SignInvitationResult {
return sign3pidInvitationTask.execute(Sign3pidInvitationTask.Params(
url = identiyServer,
token = token,
privateKey = secret
))
}
override fun addListener(listener: IdentityServiceListener) {
listeners.add(listener)
}

View file

@ -26,10 +26,12 @@ import org.matrix.android.sdk.internal.session.identity.model.IdentityRequestOwn
import org.matrix.android.sdk.internal.session.identity.model.IdentityRequestTokenForEmailBody
import org.matrix.android.sdk.internal.session.identity.model.IdentityRequestTokenForMsisdnBody
import org.matrix.android.sdk.internal.session.identity.model.IdentityRequestTokenResponse
import org.matrix.android.sdk.internal.session.identity.model.SignInvitationResult
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
/**
* Ref: https://matrix.org/docs/spec/identity_service/latest
@ -95,4 +97,16 @@ internal interface IdentityAPI {
@POST(NetworkConstants.URI_IDENTITY_PATH_V2 + "validate/{medium}/submitToken")
suspend fun submitToken(@Path("medium") medium: String,
@Body body: IdentityRequestOwnershipParams): SuccessResult
/**
* https://matrix.org/docs/spec/identity_service/r0.3.0#post-matrix-identity-v2-sign-ed25519
*
* Have to rely on V1 for now
*/
@POST(NetworkConstants.URI_IDENTITY_PATH_V1 + "sign-ed25519")
suspend fun signInvitationDetails(
@Query("token") token: String,
@Query("private_key") privateKey: String,
@Query("mxid") mxid: String
): SignInvitationResult
}

View file

@ -0,0 +1,49 @@
/*
* Copyright 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.internal.session.identity
import dagger.Lazy
import okhttp3.OkHttpClient
import org.matrix.android.sdk.internal.di.Unauthenticated
import org.matrix.android.sdk.internal.di.UserId
import org.matrix.android.sdk.internal.network.RetrofitFactory
import org.matrix.android.sdk.internal.session.identity.model.SignInvitationResult
import org.matrix.android.sdk.internal.task.Task
import javax.inject.Inject
internal interface Sign3pidInvitationTask : Task<Sign3pidInvitationTask.Params, SignInvitationResult> {
data class Params(
val token: String,
val url: String,
val privateKey: String
)
}
internal class DefaultSign3pidInvitationTask @Inject constructor(
@Unauthenticated
private val okHttpClient: Lazy<OkHttpClient>,
private val retrofitFactory: RetrofitFactory,
@UserId private val userId: String
) : Sign3pidInvitationTask {
override suspend fun execute(params: Sign3pidInvitationTask.Params): SignInvitationResult {
val identityAPI = retrofitFactory
.create(okHttpClient, "https://${params.url}")
.create(IdentityAPI::class.java)
return identityAPI.signInvitationDetails(params.token, params.privateKey, userId)
}
}

View file

@ -0,0 +1,29 @@
/*
* Copyright 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.internal.session.identity.model
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
internal data class SignInvitationBody(
/**The Matrix user ID of the user accepting the invitation.*/
val mxid: String,
/**The token from the call to store- invite..*/
val token: String,
/** The private key, encoded as Unpadded base64. */
val private_key: String
)

View file

@ -0,0 +1,31 @@
/*
* Copyright 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.internal.session.identity.model
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class SignInvitationResult(
/** The Matrix user ID of the user accepting the invitation.*/
val mxid: String,
/** The Matrix user ID of the user who sent the invitation.*/
val sender: String,
/**The token from the call to store- invite..*/
val signatures: Map<String, *>,
/** The token for the invitation */
val token: String
)

View file

@ -38,6 +38,7 @@ import org.matrix.android.sdk.api.util.toOptional
import org.matrix.android.sdk.internal.database.mapper.asDomain
import org.matrix.android.sdk.internal.database.model.RoomMemberSummaryEntityFields
import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.session.identity.model.SignInvitationResult
import org.matrix.android.sdk.internal.session.room.alias.DeleteRoomAliasTask
import org.matrix.android.sdk.internal.session.room.alias.GetRoomIdByAliasTask
import org.matrix.android.sdk.internal.session.room.alias.RoomAliasDescription
@ -122,6 +123,12 @@ internal class DefaultRoomService @Inject constructor(
joinRoomTask.execute(JoinRoomTask.Params(roomIdOrAlias, reason, viaServers))
}
override suspend fun joinRoom(roomId: String,
reason: String?,
thirdPartySigned: SignInvitationResult) {
joinRoomTask.execute(JoinRoomTask.Params(roomId, reason, thirdPartySigned = thirdPartySigned))
}
override suspend fun markAllAsRead(roomIds: List<String>) {
markAllRoomsReadTask.execute(MarkAllRoomsReadTask.Params(roomIds))
}

View file

@ -254,7 +254,7 @@ internal interface RoomAPI {
@POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "join/{roomIdOrAlias}")
suspend fun join(@Path("roomIdOrAlias") roomIdOrAlias: String,
@Query("server_name") viaServers: List<String>,
@Body params: Map<String, String?>): JoinRoomResponse
@Body params: JsonDict): JoinRoomResponse
/**
* Leave the given room.

View file

@ -28,6 +28,8 @@ import org.matrix.android.sdk.api.session.space.SpaceService
import org.matrix.android.sdk.internal.session.DefaultFileService
import org.matrix.android.sdk.internal.session.SessionScope
import org.matrix.android.sdk.internal.session.directory.DirectoryAPI
import org.matrix.android.sdk.internal.session.identity.DefaultSign3pidInvitationTask
import org.matrix.android.sdk.internal.session.identity.Sign3pidInvitationTask
import org.matrix.android.sdk.internal.session.room.accountdata.DefaultUpdateRoomAccountDataTask
import org.matrix.android.sdk.internal.session.room.accountdata.UpdateRoomAccountDataTask
import org.matrix.android.sdk.internal.session.room.alias.AddRoomAliasTask
@ -248,4 +250,7 @@ internal abstract class RoomModule {
@Binds
abstract fun bindRoomVersionUpgradeTask(task: DefaultRoomVersionUpgradeTask): RoomVersionUpgradeTask
@Binds
abstract fun bindSign3pidInvitationTask(task: DefaultSign3pidInvitationTask): Sign3pidInvitationTask
}

View file

@ -19,6 +19,7 @@ package org.matrix.android.sdk.internal.session.room.membership.joining
import io.realm.Realm
import io.realm.RealmConfiguration
import kotlinx.coroutines.TimeoutCancellationException
import org.matrix.android.sdk.api.session.events.model.toContent
import org.matrix.android.sdk.api.session.room.failure.JoinRoomFailure
import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState
import org.matrix.android.sdk.api.session.room.model.Membership
@ -29,6 +30,7 @@ import org.matrix.android.sdk.internal.database.query.where
import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.network.GlobalErrorReceiver
import org.matrix.android.sdk.internal.network.executeRequest
import org.matrix.android.sdk.internal.session.identity.model.SignInvitationResult
import org.matrix.android.sdk.internal.session.room.RoomAPI
import org.matrix.android.sdk.internal.session.room.membership.RoomChangeMembershipStateDataSource
import org.matrix.android.sdk.internal.session.room.read.SetReadMarkersTask
@ -40,7 +42,8 @@ internal interface JoinRoomTask : Task<JoinRoomTask.Params, Unit> {
data class Params(
val roomIdOrAlias: String,
val reason: String?,
val viaServers: List<String> = emptyList()
val viaServers: List<String> = emptyList(),
val thirdPartySigned : SignInvitationResult? = null
)
}
@ -59,12 +62,16 @@ internal class DefaultJoinRoomTask @Inject constructor(
return
}
roomChangeMembershipStateDataSource.updateState(params.roomIdOrAlias, ChangeMembershipState.Joining)
val extraParams = mutableMapOf<String, Any>().apply {
params.reason?.let { this["reason"] = it }
params.thirdPartySigned?.let { this["third_party_signed"] = it.toContent() }
}
val joinRoomResponse = try {
executeRequest(globalErrorReceiver) {
roomAPI.join(
roomIdOrAlias = params.roomIdOrAlias,
viaServers = params.viaServers.take(3),
params = mapOf("reason" to params.reason)
params = extraParams
)
}
} catch (failure: Throwable) {

View file

@ -183,6 +183,7 @@ class MatrixToBottomSheetViewModel @AssistedInject constructor(
// not yet supported
_viewEvents.post(MatrixToViewEvents.Dismiss)
}
is PermalinkData.RoomEmailInviteLink,
is PermalinkData.FallbackLink -> {
_viewEvents.post(MatrixToViewEvents.Dismiss)
}

View file

@ -86,6 +86,7 @@ import im.vector.app.features.widgets.WidgetActivity
import im.vector.app.features.widgets.WidgetArgsBuilder
import im.vector.app.space
import org.matrix.android.sdk.api.session.crypto.verification.IncomingSasVerificationTransaction
import org.matrix.android.sdk.api.session.permalinks.PermalinkData
import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoom
import org.matrix.android.sdk.api.session.terms.TermsService
import org.matrix.android.sdk.api.session.widgets.model.Widget
@ -249,7 +250,7 @@ class DefaultNavigator @Inject constructor(
context.startActivity(intent)
}
override fun openRoomPreview(context: Context, roomPreviewData: RoomPreviewData) {
override fun openRoomPreview(context: Context, roomPreviewData: RoomPreviewData, fromEmailInviteLink: PermalinkData.RoomEmailInviteLink?) {
val intent = RoomPreviewActivity.newIntent(context, roomPreviewData)
context.startActivity(intent)
}

View file

@ -30,6 +30,7 @@ import im.vector.app.features.roomdirectory.RoomDirectoryData
import im.vector.app.features.roomdirectory.roompreview.RoomPreviewData
import im.vector.app.features.settings.VectorSettingsActivity
import im.vector.app.features.share.SharedData
import org.matrix.android.sdk.api.session.permalinks.PermalinkData
import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoom
import org.matrix.android.sdk.api.session.terms.TermsService
import org.matrix.android.sdk.api.session.widgets.model.Widget
@ -65,7 +66,7 @@ interface Navigator {
fun openRoomPreview(context: Context, publicRoom: PublicRoom, roomDirectoryData: RoomDirectoryData)
fun openRoomPreview(context: Context, roomPreviewData: RoomPreviewData)
fun openRoomPreview(context: Context, roomPreviewData: RoomPreviewData, fromEmailInviteLink: PermalinkData.RoomEmailInviteLink? = null)
fun openMatrixToBottomSheet(context: Context, link: String)

View file

@ -22,6 +22,7 @@ import im.vector.app.R
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.utils.toast
import im.vector.app.features.navigation.Navigator
import im.vector.app.features.roomdirectory.roompreview.RoomPreviewData
import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
@ -77,7 +78,7 @@ class PermalinkHandler @Inject constructor(private val activeSessionHolder: Acti
buildTask: Boolean
): Single<Boolean> {
return when (permalinkData) {
is PermalinkData.RoomLink -> {
is PermalinkData.RoomLink -> {
permalinkData.getRoomId()
.observeOn(AndroidSchedulers.mainThread())
.map {
@ -94,19 +95,30 @@ class PermalinkHandler @Inject constructor(private val activeSessionHolder: Acti
true
}
}
is PermalinkData.GroupLink -> {
is PermalinkData.GroupLink -> {
navigator.openGroupDetail(permalinkData.groupId, context, buildTask)
Single.just(true)
}
is PermalinkData.UserLink -> {
is PermalinkData.UserLink -> {
if (navigationInterceptor?.navToMemberProfile(permalinkData.userId, rawLink) != true) {
navigator.openRoomMemberProfile(userId = permalinkData.userId, roomId = null, context = context, buildTask = buildTask)
}
Single.just(true)
}
is PermalinkData.FallbackLink -> {
is PermalinkData.FallbackLink -> {
Single.just(false)
}
is PermalinkData.RoomEmailInviteLink -> {
val data = RoomPreviewData(
roomId = permalinkData.roomId,
roomName = permalinkData.roomName,
avatarUrl = permalinkData.roomAvatarUrl,
fromEmailInvite = permalinkData,
roomType = permalinkData.roomType
)
navigator.openRoomPreview(context, data)
Single.just(true)
}
}
}

View file

@ -20,4 +20,5 @@ import im.vector.app.core.platform.VectorViewModelAction
sealed class RoomPreviewAction : VectorViewModelAction {
object Join : RoomPreviewAction()
object JoinThirdParty : RoomPreviewAction()
}

View file

@ -27,6 +27,7 @@ import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.databinding.ActivitySimpleBinding
import im.vector.app.features.roomdirectory.RoomDirectoryData
import kotlinx.parcelize.Parcelize
import org.matrix.android.sdk.api.session.permalinks.PermalinkData
import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoom
import org.matrix.android.sdk.api.util.MatrixItem
import timber.log.Timber
@ -37,12 +38,14 @@ data class RoomPreviewData(
val eventId: String? = null,
val roomName: String? = null,
val roomAlias: String? = null,
val roomType: String? = null,
val topic: String? = null,
val worldReadable: Boolean = false,
val avatarUrl: String? = null,
val homeServers: List<String> = emptyList(),
val peekFromServer: Boolean = false,
val buildTask: Boolean = false
val buildTask: Boolean = false,
val fromEmailInvite: PermalinkData.RoomEmailInviteLink? = null
) : Parcelable {
val matrixItem: MatrixItem
get() = MatrixItem.RoomItem(roomId, roomName ?: roomAlias, avatarUrl)

View file

@ -16,12 +16,15 @@
package im.vector.app.features.roomdirectory.roompreview
import android.graphics.Typeface
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.text.toSpannable
import androidx.core.view.isVisible
import androidx.transition.TransitionManager
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.args
@ -31,10 +34,16 @@ import im.vector.app.R
import im.vector.app.core.extensions.setTextOrHide
import im.vector.app.core.platform.ButtonStateView
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.utils.styleMatchingText
import im.vector.app.core.utils.tappableMatchingText
import im.vector.app.databinding.FragmentRoomPreviewNoPreviewBinding
import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.navigation.Navigator
import im.vector.app.features.roomdirectory.JoinState
import im.vector.app.features.settings.VectorSettingsActivity
import im.vector.app.features.themes.ThemeUtils
import me.gujun.android.span.span
import org.matrix.android.sdk.api.session.room.model.RoomType
import org.matrix.android.sdk.api.util.MatrixItem
import javax.inject.Inject
@ -61,7 +70,6 @@ class RoomPreviewNoPreviewFragment @Inject constructor(
}
override fun invalidate() = withState(roomPreviewViewModel) { state ->
TransitionManager.beginDelayedTransition(views.coordinatorLayout)
views.roomPreviewNoPreviewJoin.render(
when (state.roomJoinState) {
@ -83,7 +91,11 @@ class RoomPreviewNoPreviewFragment @Inject constructor(
// Quit this screen
requireActivity().finish()
// Open room
navigator.openRoom(requireActivity(), state.roomId, roomPreviewData.eventId, roomPreviewData.buildTask)
if (state.roomType == RoomType.SPACE) {
navigator.switchToSpace(requireActivity(), state.roomId, Navigator.PostSwitchSpaceAction.None)
} else {
navigator.openRoom(requireActivity(), state.roomId, roomPreviewData.eventId, roomPreviewData.buildTask)
}
}
val bestName = state.roomName ?: state.roomAlias ?: state.roomId
@ -98,19 +110,57 @@ class RoomPreviewNoPreviewFragment @Inject constructor(
PeekingState.FOUND -> {
// show join buttons
views.roomPreviewNoPreviewJoin.isVisible = true
renderState(bestName, state.matrixItem(), state.roomTopic)
renderState(bestName, state.matrixItem(), state.roomTopic
/**, state.roomType*/)
if (state.fromEmailInvite != null && !state.isEmailBoundToAccount) {
views.roomPreviewNoPreviewLabel.text =
span {
span {
textColor = ThemeUtils.getColor(requireContext(), R.attr.vctr_content_primary)
text = if (state.roomType == RoomType.SPACE) {
getString(R.string.this_invite_to_this_space_was_sent, state.fromEmailInvite.email)
} else {
getString(R.string.this_invite_to_this_room_was_sent, state.fromEmailInvite.email)
}
.toSpannable()
.styleMatchingText(state.fromEmailInvite.email, Typeface.BOLD)
}
+"\n"
span {
text = getString(
R.string.link_this_email_with_your_account,
getString(R.string.link_this_email_settings_link)
)
.toSpannable()
.tappableMatchingText(getString(R.string.link_this_email_settings_link), object : ClickableSpan() {
override fun onClick(widget: View) {
navigator.openSettings(
requireContext(),
VectorSettingsActivity.EXTRA_DIRECT_ACCESS_DISCOVERY_SETTINGS
)
}
})
}
}
views.roomPreviewNoPreviewLabel.movementMethod = LinkMovementMethod.getInstance()
views.roomPreviewNoPreviewJoin.commonClicked = {
roomPreviewViewModel.handle(RoomPreviewAction.JoinThirdParty)
}
}
}
PeekingState.NO_ACCESS -> {
views.roomPreviewNoPreviewJoin.isVisible = true
views.roomPreviewNoPreviewLabel.isVisible = true
views.roomPreviewNoPreviewLabel.setText(R.string.room_preview_no_preview_join)
renderState(bestName, state.matrixItem().takeIf { state.roomAlias != null }, state.roomTopic)
renderState(bestName, state.matrixItem().takeIf { state.roomAlias != null }, state.roomTopic
/**, state.roomType*/)
}
else -> {
views.roomPreviewNoPreviewJoin.isVisible = false
views.roomPreviewNoPreviewLabel.isVisible = true
views.roomPreviewNoPreviewLabel.setText(R.string.room_preview_not_found)
renderState(bestName, null, state.roomTopic)
renderState(bestName, null, state.roomTopic
/**, state.roomType*/)
}
}
}
@ -118,13 +168,16 @@ class RoomPreviewNoPreviewFragment @Inject constructor(
// Render with initial state, no peeking
views.roomPreviewPeekingProgress.isVisible = false
views.roomPreviewNoPreviewJoin.isVisible = true
renderState(bestName, state.matrixItem(), state.roomTopic)
renderState(bestName, state.matrixItem(), state.roomTopic
/**, state.roomType*/)
views.roomPreviewNoPreviewLabel.isVisible = false
}
}
}
private fun renderState(roomName: String, matrixItem: MatrixItem?, topic: String?) {
private fun renderState(roomName: String, matrixItem: MatrixItem?, topic: String?
/**, roomType: String?*/
) {
// Toolbar
if (matrixItem != null) {
views.roomPreviewNoPreviewToolbarAvatar.isVisible = true

View file

@ -23,8 +23,8 @@ import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.ViewModelContext
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.EmptyViewEvents
import im.vector.app.core.platform.VectorViewModel
@ -34,6 +34,8 @@ import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.query.QueryStringValue
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.identity.SharedState
import org.matrix.android.sdk.api.session.identity.ThreePid
import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState
import org.matrix.android.sdk.api.session.room.model.Membership
import org.matrix.android.sdk.api.session.room.peeking.PeekResult
@ -64,7 +66,39 @@ class RoomPreviewViewModel @AssistedInject constructor(@Assisted private val ini
observeRoomSummary()
observeMembershipChanges()
if (initialState.shouldPeekFromServer) {
if (initialState.fromEmailInvite != null) {
setState {
copy(peekingState = Loading())
}
viewModelScope.launch {
// we might want to check if the mail is bound to this account?
// if it is the invite
val threePids = session
.getThreePids()
.filterIsInstance<ThreePid.Email>()
val status = if (threePids.indexOfFirst { it.email == initialState.fromEmailInvite.email } != -1) {
try {
session.identityService().getShareStatus(threePids)
} catch (failure: Throwable) {
Timber.w(failure, "## Room Invite: Failed to get 3pids shared status")
// If terms not signed, or no identity server setup, or different
// id server from the one in the email invite, we consider the mails as not bound
emptyMap()
}.firstNotNullOfOrNull { if (it.key.value == initialState.fromEmailInvite.email) it.value else null }
?: SharedState.NOT_SHARED
} else {
SharedState.NOT_SHARED
}
setState {
copy(
isEmailBoundToAccount = status == SharedState.SHARED,
peekingState = Success(PeekingState.FOUND)
)
}
}
} else if (initialState.shouldPeekFromServer) {
peekRoomFromServer()
}
}
@ -128,6 +162,7 @@ class RoomPreviewViewModel @AssistedInject constructor(@Assisted private val ini
private fun observeRoomSummary() {
val queryParams = roomSummaryQueryParams {
roomId = QueryStringValue.Equals(initialState.roomId)
excludeType = null
}
session
.rx()
@ -136,6 +171,11 @@ class RoomPreviewViewModel @AssistedInject constructor(@Assisted private val ini
val isRoomJoined = list.any {
it.membership == Membership.JOIN
}
list.firstOrNull { it.roomId == initialState.roomId }?.roomType?.let {
setState {
copy(roomType = it)
}
}
if (isRoomJoined) {
setState { copy(roomJoinState = JoinState.JOINED) }
}
@ -163,10 +203,43 @@ class RoomPreviewViewModel @AssistedInject constructor(@Assisted private val ini
override fun handle(action: RoomPreviewAction) {
when (action) {
is RoomPreviewAction.Join -> handleJoinRoom()
is RoomPreviewAction.Join -> handleJoinRoom()
RoomPreviewAction.JoinThirdParty -> handleJoinRoomThirdParty()
}.exhaustive
}
private fun handleJoinRoomThirdParty() = withState { state ->
if (state.roomJoinState == JoinState.JOINING) {
// Request already sent, should not happen
Timber.w("Try to join an already joining room. Should not happen")
return@withState
}
// local echo
setState {
copy(roomJoinState = JoinState.JOINING)
}
viewModelScope.launch {
try {
// XXX this could be done locally, but the spec is incomplete and it's not clear
// what needs to be signed with what?
val thirdPartySigned = session.identityService().sign3pidInvitation(
state.fromEmailInvite?.identityServer ?: "",
state.fromEmailInvite?.token ?: "",
state.fromEmailInvite?.privateKey ?: ""
)
session.joinRoom(state.roomId, reason = null, thirdPartySigned)
} catch (failure: Throwable) {
setState {
copy(
roomJoinState = JoinState.JOINING_ERROR,
lastError = failure
)
}
}
}
}
private fun handleJoinRoom() = withState { state ->
if (state.roomJoinState == JoinState.JOINING) {
// Request already sent, should not happen

View file

@ -20,6 +20,8 @@ import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.Uninitialized
import im.vector.app.features.roomdirectory.JoinState
import org.matrix.android.sdk.api.session.permalinks.PermalinkData
import org.matrix.android.sdk.api.session.room.model.RoomType
import org.matrix.android.sdk.api.util.MatrixItem
data class RoomPreviewViewState(
@ -27,6 +29,7 @@ data class RoomPreviewViewState(
// The room id
val roomId: String = "",
val roomAlias: String? = null,
val roomType: String? = null,
val roomName: String? = null,
val roomTopic: String? = null,
@ -40,7 +43,11 @@ data class RoomPreviewViewState(
// Current state of the room in preview
val roomJoinState: JoinState = JoinState.NOT_JOINED,
// Last error of join room request
val lastError: Throwable? = null
val lastError: Throwable? = null,
val fromEmailInvite: PermalinkData.RoomEmailInviteLink? = null,
// used only if it's an email invite
val isEmailBoundToAccount: Boolean = false
) : MvRxState {
constructor(args: RoomPreviewData) : this(
@ -50,10 +57,13 @@ data class RoomPreviewViewState(
roomName = args.roomName,
roomTopic = args.topic,
avatarUrl = args.avatarUrl,
shouldPeekFromServer = args.peekFromServer
shouldPeekFromServer = args.peekFromServer,
fromEmailInvite = args.fromEmailInvite,
roomType = args.roomType
)
fun matrixItem() : MatrixItem {
return MatrixItem.RoomItem(roomId, roomName ?: roomAlias, avatarUrl)
return if (roomType == RoomType.SPACE) MatrixItem.SpaceItem(roomId, roomName ?: roomAlias, avatarUrl)
else MatrixItem.RoomItem(roomId, roomName ?: roomAlias, avatarUrl)
}
}

View file

@ -26,6 +26,7 @@ import im.vector.app.core.di.ScreenComponent
import im.vector.app.core.extensions.replaceFragment
import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.databinding.ActivityVectorSettingsBinding
import im.vector.app.features.discovery.DiscoverySettingsFragment
import im.vector.app.features.settings.devices.VectorSettingsDevicesFragment
import im.vector.app.features.settings.notifications.VectorSettingsNotificationPreferenceFragment
@ -79,6 +80,9 @@ class VectorSettingsActivity : VectorBaseActivity<ActivityVectorSettingsBinding>
requestHighlightPreferenceKeyOnResume(VectorPreferences.SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY)
replaceFragment(R.id.vector_settings_page, VectorSettingsNotificationPreferenceFragment::class.java, null, FRAGMENT_TAG)
}
EXTRA_DIRECT_ACCESS_DISCOVERY_SETTINGS -> {
replaceFragment(R.id.vector_settings_page, DiscoverySettingsFragment::class.java, null, FRAGMENT_TAG)
}
else ->
replaceFragment(R.id.vector_settings_page, VectorSettingsRootFragment::class.java, null, FRAGMENT_TAG)
@ -160,6 +164,7 @@ class VectorSettingsActivity : VectorBaseActivity<ActivityVectorSettingsBinding>
const val EXTRA_DIRECT_ACCESS_SECURITY_PRIVACY_MANAGE_SESSIONS = 3
const val EXTRA_DIRECT_ACCESS_GENERAL = 4
const val EXTRA_DIRECT_ACCESS_NOTIFICATIONS = 5
const val EXTRA_DIRECT_ACCESS_DISCOVERY_SETTINGS = 6
private const val FRAGMENT_TAG = "VectorSettingsPreferencesFragment"
}

View file

@ -3550,4 +3550,13 @@
<string name="this_makes_it_easy_for_rooms_to_stay_private_to_a_space">This makes it easy for rooms to stay private to a space, while letting people in the space find and join them. All new rooms in a space will have this option available.</string>
<string name="help_space_members">Help space members find private rooms</string>
<string name="to_help_space_members_find_and_join">To help space members find and join a private room, go to that rooms settings by tapping on the avatar.</string>
<!-- %s will be replaced by an email at runtime -->
<string name="this_invite_to_this_room_was_sent">This invite to this room was sent to %s which is not associated with your account</string>
<!-- %s will be replaced by an email at runtime -->
<string name="this_invite_to_this_space_was_sent">This invite to this space was sent to %s which is not associated with your account</string>
<string name="link_this_email_settings_link">Link this email with your account</string>
<!-- %s will be replaced by the value of link_this_email_settings_link and styled as a link -->
<string name="link_this_email_with_your_account">%s in Settings to receive invites directly in Element.</string>
</resources>