Merge pull request #2377 from vector-im/feature/bma/consent

Feature/bma/consent
This commit is contained in:
Benoit Marty 2020-11-16 13:43:30 +01:00 committed by GitHub
commit 0022777a4f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 279 additions and 22 deletions

View file

@ -6,6 +6,7 @@ Features ✨:
Improvements 🙌:
- Open an existing DM instead of creating a new one (#2319)
- Ask for explicit user consent to send their contact details to the identity server (#2375)
- Handle events of type "m.room.server_acl" (#890)
Bugfix 🐛:

View file

@ -92,9 +92,29 @@ interface IdentityService {
/**
* Search MatrixId of users providing email and phone numbers
* Note the the user consent has to be set to true, or it will throw a UserConsentNotProvided failure
* Application has to explicitly ask for the user consent, and the answer can be stored using [setUserConsent]
* Please see https://support.google.com/googleplay/android-developer/answer/9888076?hl=en for more details.
*/
fun lookUp(threePids: List<ThreePid>, callback: MatrixCallback<List<FoundThreePid>>): Cancelable
/**
* Return the current user consent for the current identity server, which has been stored using [setUserConsent].
* If [setUserConsent] has not been called, the returned value will be false.
* Note that if the identity server is changed, the user consent is reset to false.
* @return the value stored using [setUserConsent] or false if [setUserConsent] has never been called, or if the identity server
* has been changed
*/
fun getUserConsent(): Boolean
/**
* Set the user consent to the provided value. Application MUST explicitly ask for the user consent to send their private data
* (email and phone numbers) to the identity server.
* Please see https://support.google.com/googleplay/android-developer/answer/9888076?hl=en for more details.
* @param newValue true if the user explicitly give their consent, false if the user wants to revoke their consent.
*/
fun setUserConsent(newValue: Boolean)
/**
* Get the status of the current user's threePid
* A lookup will be performed, but also pending binding state will be restored

View file

@ -24,6 +24,7 @@ sealed class IdentityServiceError : Failure.FeatureFailure() {
object NoIdentityServerConfigured : IdentityServiceError()
object TermsNotSignedException : IdentityServiceError()
object BulkLookupSha256NotSupported : IdentityServiceError()
object UserConsentNotProvided : IdentityServiceError()
object BindingError : IdentityServiceError()
object NoCurrentBindingError : IdentityServiceError()
}

View file

@ -55,6 +55,7 @@ import org.matrix.android.sdk.internal.util.MatrixCoroutineDispatchers
import org.matrix.android.sdk.internal.util.ensureProtocol
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.matrix.android.sdk.api.extensions.orFalse
import timber.log.Timber
import javax.inject.Inject
import javax.net.ssl.HttpsURLConnection
@ -243,7 +244,20 @@ internal class DefaultIdentityService @Inject constructor(
))
}
override fun getUserConsent(): Boolean {
return identityStore.getIdentityData()?.userConsent.orFalse()
}
override fun setUserConsent(newValue: Boolean) {
identityStore.setUserConsent(newValue)
}
override fun lookUp(threePids: List<ThreePid>, callback: MatrixCallback<List<FoundThreePid>>): Cancelable {
if (!getUserConsent()) {
callback.onFailure(IdentityServiceError.UserConsentNotProvided)
return NoOpCancellable
}
if (threePids.isEmpty()) {
callback.onSuccess(emptyList())
return NoOpCancellable
@ -255,6 +269,9 @@ internal class DefaultIdentityService @Inject constructor(
}
override fun getShareStatus(threePids: List<ThreePid>, callback: MatrixCallback<Map<ThreePid, SharedState>>): Cancelable {
// Note: we do not require user consent here, because it is used for emails and phone numbers that the user has already sent
// to the home server, and not emails and phone numbers from the contact book of the user
if (threePids.isEmpty()) {
callback.onSuccess(emptyMap())
return NoOpCancellable

View file

@ -34,6 +34,7 @@ import org.matrix.android.sdk.internal.session.identity.db.IdentityRealmModule
import org.matrix.android.sdk.internal.session.identity.db.RealmIdentityStore
import io.realm.RealmConfiguration
import okhttp3.OkHttpClient
import org.matrix.android.sdk.internal.session.identity.db.RealmIdentityStoreMigration
import java.io.File
@Module
@ -59,6 +60,7 @@ internal abstract class IdentityModule {
@SessionScope
fun providesIdentityRealmConfiguration(realmKeysUtils: RealmKeysUtils,
@SessionFilesDirectory directory: File,
migration: RealmIdentityStoreMigration,
@UserMd5 userMd5: String): RealmConfiguration {
return RealmConfiguration.Builder()
.directory(directory)
@ -66,6 +68,8 @@ internal abstract class IdentityModule {
.apply {
realmKeysUtils.configureEncryption(this, SessionModule.getKeyAlias(userMd5))
}
.schemaVersion(RealmIdentityStoreMigration.IDENTITY_STORE_SCHEMA_VERSION)
.migration(migration)
.allowWritesOnUiThread(true)
.modules(IdentityRealmModule())
.build()

View file

@ -20,5 +20,6 @@ internal data class IdentityData(
val identityServerUrl: String?,
val token: String?,
val hashLookupPepper: String?,
val hashLookupAlgorithm: List<String>
val hashLookupAlgorithm: List<String>,
val userConsent: Boolean
)

View file

@ -27,6 +27,8 @@ internal interface IdentityStore {
fun setToken(token: String?)
fun setUserConsent(consent: Boolean)
fun setHashDetails(hashDetailResponse: IdentityHashDetailResponse)
/**

View file

@ -23,7 +23,8 @@ internal open class IdentityDataEntity(
var identityServerUrl: String? = null,
var token: String? = null,
var hashLookupPepper: String? = null,
var hashLookupAlgorithm: RealmList<String> = RealmList()
var hashLookupAlgorithm: RealmList<String> = RealmList(),
var userConsent: Boolean = false
) : RealmObject() {
companion object

View file

@ -52,6 +52,13 @@ internal fun IdentityDataEntity.Companion.setToken(realm: Realm,
}
}
internal fun IdentityDataEntity.Companion.setUserConsent(realm: Realm,
newConsent: Boolean) {
get(realm)?.apply {
userConsent = newConsent
}
}
internal fun IdentityDataEntity.Companion.setHashDetails(realm: Realm,
pepper: String,
algorithms: List<String>) {

View file

@ -26,7 +26,8 @@ internal object IdentityMapper {
identityServerUrl = entity.identityServerUrl,
token = entity.token,
hashLookupPepper = entity.hashLookupPepper,
hashLookupAlgorithm = entity.hashLookupAlgorithm.toList()
hashLookupAlgorithm = entity.hashLookupAlgorithm.toList(),
userConsent = entity.userConsent
)
}

View file

@ -55,6 +55,14 @@ internal class RealmIdentityStore @Inject constructor(
}
}
override fun setUserConsent(consent: Boolean) {
Realm.getInstance(realmConfiguration).use {
it.executeTransaction { realm ->
IdentityDataEntity.setUserConsent(realm, consent)
}
}
}
override fun setHashDetails(hashDetailResponse: IdentityHashDetailResponse) {
Realm.getInstance(realmConfiguration).use {
it.executeTransaction { realm ->

View file

@ -0,0 +1,43 @@
/*
* Copyright 2020 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.db
import io.realm.DynamicRealm
import io.realm.RealmMigration
import timber.log.Timber
import javax.inject.Inject
internal class RealmIdentityStoreMigration @Inject constructor() : RealmMigration {
companion object {
const val IDENTITY_STORE_SCHEMA_VERSION = 1L
}
override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) {
Timber.v("Migrating Realm Identity from $oldVersion to $newVersion")
if (oldVersion <= 0) migrateTo1(realm)
}
private fun migrateTo1(realm: DynamicRealm) {
Timber.d("Step 0 -> 1")
Timber.d("Add field userConsent (Boolean) and set the value to false")
realm.schema.get("IdentityDataEntity")
?.addField(IdentityDataEntityFields.USER_CONSENT, Boolean::class.java)
}
}

View file

@ -136,6 +136,7 @@ class DefaultErrorFormatter @Inject constructor(
IdentityServiceError.BulkLookupSha256NotSupported -> R.string.identity_server_error_bulk_sha256_not_supported
IdentityServiceError.BindingError -> R.string.identity_server_error_binding_error
IdentityServiceError.NoCurrentBindingError -> R.string.identity_server_error_no_current_binding_error
IdentityServiceError.UserConsentNotProvided -> R.string.identity_server_user_consent_not_provided
})
}
}

View file

@ -21,4 +21,5 @@ import im.vector.app.core.platform.VectorViewModelAction
sealed class ContactsBookAction : VectorViewModelAction {
data class FilterWith(val filter: String) : ContactsBookAction()
data class OnlyBoundContacts(val onlyBoundContacts: Boolean) : ContactsBookAction()
object UserConsentGranted : ContactsBookAction()
}

View file

@ -52,11 +52,10 @@ class ContactsBookController @Inject constructor(
override fun buildModels() {
val currentState = state ?: return
val hasSearch = currentState.searchTerm.isNotEmpty()
when (val asyncMappedContacts = currentState.mappedContacts) {
is Uninitialized -> renderEmptyState(false)
is Loading -> renderLoading()
is Success -> renderSuccess(currentState.filteredMappedContacts, hasSearch, currentState.onlyBoundContacts)
is Success -> renderSuccess(currentState)
is Fail -> renderFailure(asyncMappedContacts.error)
}
}
@ -75,13 +74,13 @@ class ContactsBookController @Inject constructor(
}
}
private fun renderSuccess(mappedContacts: List<MappedContact>,
hasSearch: Boolean,
onlyBoundContacts: Boolean) {
private fun renderSuccess(state: ContactsBookViewState) {
val mappedContacts = state.filteredMappedContacts
if (mappedContacts.isEmpty()) {
renderEmptyState(hasSearch)
renderEmptyState(state.searchTerm.isNotEmpty())
} else {
renderContacts(mappedContacts, onlyBoundContacts)
renderContacts(mappedContacts, state.onlyBoundContacts)
}
}

View file

@ -18,6 +18,7 @@ package im.vector.app.features.contactsbook
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible
import com.airbnb.mvrx.activityViewModel
import com.airbnb.mvrx.withState
@ -57,10 +58,26 @@ class ContactsBookFragment @Inject constructor(
sharedActionViewModel = activityViewModelProvider.get(UserDirectorySharedActionViewModel::class.java)
setupRecyclerView()
setupFilterView()
setupConsentView()
setupOnlyBoundContactsView()
setupCloseView()
}
private fun setupConsentView() {
phoneBookSearchForMatrixContacts.setOnClickListener {
withState(contactsBookViewModel) { state ->
AlertDialog.Builder(requireActivity())
.setTitle(R.string.identity_server_consent_dialog_title)
.setMessage(getString(R.string.identity_server_consent_dialog_content, state.identityServerUrl ?: ""))
.setPositiveButton(R.string.yes) { _, _ ->
contactsBookViewModel.handle(ContactsBookAction.UserConsentGranted)
}
.setNegativeButton(R.string.no, null)
.show()
}
}
}
private fun setupOnlyBoundContactsView() {
phoneBookOnlyBoundContacts.checkedChanges()
.subscribe {
@ -98,6 +115,7 @@ class ContactsBookFragment @Inject constructor(
}
override fun invalidate() = withState(contactsBookViewModel) { state ->
phoneBookSearchForMatrixContacts.isVisible = state.filteredMappedContacts.isNotEmpty() && state.identityServerUrl != null && !state.userConsent
phoneBookOnlyBoundContacts.isVisible = state.isBoundRetrieved
contactsBookController.setData(state)
}

View file

@ -38,11 +38,10 @@ import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.MatrixCallback
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.identity.FoundThreePid
import org.matrix.android.sdk.api.session.identity.IdentityServiceError
import org.matrix.android.sdk.api.session.identity.ThreePid
import timber.log.Timber
private typealias PhoneBookSearch = String
class ContactsBookViewModel @AssistedInject constructor(@Assisted
initialState: ContactsBookViewState,
private val contactsDataSource: ContactsDataSource,
@ -85,7 +84,9 @@ class ContactsBookViewModel @AssistedInject constructor(@Assisted
private fun loadContacts() {
setState {
copy(
mappedContacts = Loading()
mappedContacts = Loading(),
identityServerUrl = session.identityService().getCurrentIdentityServerUrl(),
userConsent = session.identityService().getUserConsent()
)
}
@ -109,6 +110,9 @@ class ContactsBookViewModel @AssistedInject constructor(@Assisted
}
private fun performLookup(data: List<MappedContact>) {
if (!session.identityService().getUserConsent()) {
return
}
viewModelScope.launch {
val threePids = data.flatMap { contact ->
contact.emails.map { ThreePid.Email(it.email) } +
@ -116,8 +120,14 @@ class ContactsBookViewModel @AssistedInject constructor(@Assisted
}
session.identityService().lookUp(threePids, object : MatrixCallback<List<FoundThreePid>> {
override fun onFailure(failure: Throwable) {
// Ignore
Timber.w(failure, "Unable to perform the lookup")
// Should not happen, but just to be sure
if (failure is IdentityServiceError.UserConsentNotProvided) {
setState {
copy(userConsent = false)
}
}
}
override fun onSuccess(data: List<FoundThreePid>) {
@ -171,9 +181,21 @@ class ContactsBookViewModel @AssistedInject constructor(@Assisted
when (action) {
is ContactsBookAction.FilterWith -> handleFilterWith(action)
is ContactsBookAction.OnlyBoundContacts -> handleOnlyBoundContacts(action)
ContactsBookAction.UserConsentGranted -> handleUserConsentGranted()
}.exhaustive
}
private fun handleUserConsentGranted() {
session.identityService().setUserConsent(true)
setState {
copy(userConsent = true)
}
// Perform the lookup
performLookup(allContacts)
}
private fun handleOnlyBoundContacts(action: ContactsBookAction.OnlyBoundContacts) {
setState {
copy(

View file

@ -26,10 +26,14 @@ data class ContactsBookViewState(
val mappedContacts: Async<List<MappedContact>> = Loading(),
// Use to filter contacts by display name
val searchTerm: String = "",
// Tru to display only bound contacts with their bound 2pid
// True to display only bound contacts with their bound 2pid
val onlyBoundContacts: Boolean = false,
// All contacts, filtered by searchTerm and onlyBoundContacts
val filteredMappedContacts: List<MappedContact> = emptyList(),
// True when the identity service has return some data
val isBoundRetrieved: Boolean = false
val isBoundRetrieved: Boolean = false,
// The current identity server url if any
val identityServerUrl: String? = null,
// User consent to perform lookup (send emails to the identity server)
val userConsent: Boolean = false
) : MvRxState

View file

@ -25,6 +25,7 @@ sealed class DiscoverySettingsAction : VectorViewModelAction {
object DisconnectIdentityServer : DiscoverySettingsAction()
data class ChangeIdentityServer(val url: String) : DiscoverySettingsAction()
data class UpdateUserConsent(val newConsent: Boolean) : DiscoverySettingsAction()
data class RevokeThreePid(val threePid: ThreePid) : DiscoverySettingsAction()
data class ShareThreePid(val threePid: ThreePid) : DiscoverySettingsAction()
data class FinalizeBind3pid(val threePid: ThreePid) : DiscoverySettingsAction()

View file

@ -65,6 +65,7 @@ class DiscoverySettingsController @Inject constructor(
buildIdentityServerSection(data)
val hasIdentityServer = data.identityServer().isNullOrBlank().not()
if (hasIdentityServer && !data.termsNotSigned) {
buildConsentSection(data)
buildEmailsSection(data.emailList)
buildMsisdnSection(data.phoneNumbersList)
}
@ -72,6 +73,38 @@ class DiscoverySettingsController @Inject constructor(
}
}
private fun buildConsentSection(data: DiscoverySettingsState) {
settingsSectionTitleItem {
id("idConsentTitle")
titleResId(R.string.settings_discovery_consent_title)
}
if (data.userConsent) {
settingsInfoItem {
id("idConsentInfo")
helperTextResId(R.string.settings_discovery_consent_notice_on)
}
settingsButtonItem {
id("idConsentButton")
colorProvider(colorProvider)
buttonTitleId(R.string.settings_discovery_consent_action_revoke)
buttonStyle(ButtonStyle.DESTRUCTIVE)
buttonClickListener { listener?.onTapUpdateUserConsent(false) }
}
} else {
settingsInfoItem {
id("idConsentInfo")
helperTextResId(R.string.settings_discovery_consent_notice_off)
}
settingsButtonItem {
id("idConsentButton")
colorProvider(colorProvider)
buttonTitleId(R.string.settings_discovery_consent_action_give_consent)
buttonClickListener { listener?.onTapUpdateUserConsent(true) }
}
}
}
private fun buildIdentityServerSection(data: DiscoverySettingsState) {
val identityServer = data.identityServer() ?: stringProvider.getString(R.string.none)
@ -359,6 +392,7 @@ class DiscoverySettingsController @Inject constructor(
fun sendMsisdnVerificationCode(threePid: ThreePid.Msisdn, code: String)
fun onTapChangeIdentityServer()
fun onTapDisconnectIdentityServer()
fun onTapUpdateUserConsent(newValue: Boolean)
fun onTapRetryToRetrieveBindings()
}
}

View file

@ -170,6 +170,23 @@ class DiscoverySettingsFragment @Inject constructor(
}
}
override fun onTapUpdateUserConsent(newValue: Boolean) {
if (newValue) {
withState(viewModel) { state ->
AlertDialog.Builder(requireActivity())
.setTitle(R.string.identity_server_consent_dialog_title)
.setMessage(getString(R.string.identity_server_consent_dialog_content, state.identityServer.invoke()))
.setPositiveButton(R.string.yes) { _, _ ->
viewModel.handle(DiscoverySettingsAction.UpdateUserConsent(true))
}
.setNegativeButton(R.string.no, null)
.show()
}
} else {
viewModel.handle(DiscoverySettingsAction.UpdateUserConsent(false))
}
}
override fun onTapRetryToRetrieveBindings() {
viewModel.handle(DiscoverySettingsAction.RetrieveBinding)
}

View file

@ -25,5 +25,6 @@ data class DiscoverySettingsState(
val emailList: Async<List<PidInfo>> = Uninitialized,
val phoneNumbersList: Async<List<PidInfo>> = Uninitialized,
// Can be true if terms are updated
val termsNotSigned: Boolean = false
val termsNotSigned: Boolean = false,
val userConsent: Boolean = false
) : MvRxState

View file

@ -63,7 +63,10 @@ class DiscoverySettingsViewModel @AssistedInject constructor(
val identityServerUrl = identityService.getCurrentIdentityServerUrl()
val currentIS = state.identityServer()
setState {
copy(identityServer = Success(identityServerUrl))
copy(
identityServer = Success(identityServerUrl),
userConsent = false
)
}
if (currentIS != identityServerUrl) retrieveBinding()
}
@ -71,7 +74,10 @@ class DiscoverySettingsViewModel @AssistedInject constructor(
init {
setState {
copy(identityServer = Success(identityService.getCurrentIdentityServerUrl()))
copy(
identityServer = Success(identityService.getCurrentIdentityServerUrl()),
userConsent = identityService.getUserConsent()
)
}
startListenToIdentityManager()
observeThreePids()
@ -97,6 +103,7 @@ class DiscoverySettingsViewModel @AssistedInject constructor(
DiscoverySettingsAction.RetrieveBinding -> retrieveBinding()
DiscoverySettingsAction.DisconnectIdentityServer -> disconnectIdentityServer()
is DiscoverySettingsAction.ChangeIdentityServer -> changeIdentityServer(action)
is DiscoverySettingsAction.UpdateUserConsent -> handleUpdateUserConsent(action)
is DiscoverySettingsAction.RevokeThreePid -> revokeThreePid(action)
is DiscoverySettingsAction.ShareThreePid -> shareThreePid(action)
is DiscoverySettingsAction.FinalizeBind3pid -> finalizeBind3pid(action, true)
@ -105,13 +112,23 @@ class DiscoverySettingsViewModel @AssistedInject constructor(
}.exhaustive
}
private fun handleUpdateUserConsent(action: DiscoverySettingsAction.UpdateUserConsent) {
identityService.setUserConsent(action.newConsent)
setState { copy(userConsent = action.newConsent) }
}
private fun disconnectIdentityServer() {
setState { copy(identityServer = Loading()) }
viewModelScope.launch {
try {
awaitCallback<Unit> { session.identityService().disconnect(it) }
setState { copy(identityServer = Success(null)) }
setState {
copy(
identityServer = Success(null),
userConsent = false
)
}
} catch (failure: Throwable) {
setState { copy(identityServer = Fail(failure)) }
}
@ -126,7 +143,12 @@ class DiscoverySettingsViewModel @AssistedInject constructor(
val data = awaitCallback<String?> {
session.identityService().setNewIdentityServer(action.url, it)
}
setState { copy(identityServer = Success(data)) }
setState {
copy(
identityServer = Success(data),
userConsent = false
)
}
retrieveBinding()
} catch (failure: Throwable) {
setState { copy(identityServer = Fail(failure)) }

View file

@ -93,6 +93,27 @@
app:layout_constraintTop_toBottomOf="@+id/phoneBookFilterContainer"
tools:visibility="visible" />
<com.google.android.material.button.MaterialButton
android:id="@+id/phoneBookSearchForMatrixContacts"
style="@style/VectorButtonStyleText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/layout_horizontal_margin"
android:layout_marginTop="4dp"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:text="@string/phone_book_perform_lookup"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/phoneBookFilterContainer"
tools:visibility="visible" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/phoneBookBottomBarrier"
android:layout_width="0dp"
android:layout_height="0dp"
app:barrierDirection="bottom"
app:constraint_referenced_ids="phoneBookSearchForMatrixContacts,phoneBookOnlyBoundContacts" />
<View
android:id="@+id/phoneBookFilterDivider"
android:layout_width="0dp"
@ -101,7 +122,7 @@
android:background="?attr/vctr_list_divider_color"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/phoneBookOnlyBoundContacts" />
app:layout_constraintTop_toBottomOf="@+id/phoneBookBottomBarrier" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/phoneBookRecyclerView"

View file

@ -1793,6 +1793,14 @@
<string name="settings_discovery_confirm_mail">We sent you a confirm email to %s, check your email and click on the confirmation link</string>
<string name="settings_discovery_confirm_mail_not_clicked">We sent you a confirm email to %s, please first check your email and click on the confirmation link</string>
<string name="settings_discovery_mail_pending">Pending</string>
<string name="settings_discovery_consent_title">Send emails and phone numbers</string>
<string name="settings_discovery_consent_notice_on">You have given your consent to send emails and phone numbers to this identity server to discover other users from your contacts.</string>
<string name="settings_discovery_consent_notice_off">You have not given your consent to send emails and phone numbers to this identity server to discover other users from your contacts.</string>
<string name="settings_discovery_consent_action_revoke">Revoke my consent</string>
<string name="settings_discovery_consent_action_give_consent">Give consent</string>
<string name="identity_server_consent_dialog_title">Send emails and phone numbers</string>
<string name="identity_server_consent_dialog_content">In order to discover existing contacts you know, do you accept to send your contact data (phone numbers and/or emails) to the configured Identity Server (%1$s)?\n\nFor more privacy, the sent data will be hashed before being sent.</string>
<string name="settings_discovery_enter_identity_server">Enter an identity server URL</string>
<string name="settings_discovery_bad_identity_server">Could not connect to identity server</string>
@ -2527,6 +2535,7 @@
<string name="identity_server_error_bulk_sha256_not_supported">For your privacy, Element only supports sending hashed user emails and phone number.</string>
<string name="identity_server_error_binding_error">The association has failed.</string>
<string name="identity_server_error_no_current_binding_error">The is no current association with this identifier.</string>
<string name="identity_server_user_consent_not_provided">The user consent has not been provided.</string>
<string name="identity_server_set_default_notice">Your homeserver (%1$s) proposes to use %2$s for your identity server</string>
<string name="identity_server_set_default_submit">Use %1$s</string>
@ -2593,6 +2602,7 @@
<string name="loading_contact_book">Retrieving your contacts…</string>
<string name="empty_contact_book">Your contact book is empty</string>
<string name="contacts_book_title">Contacts book</string>
<string name="phone_book_perform_lookup">Search for contacts on Matrix</string>
<string name="three_pid_revoke_invite_dialog_title">Revoke invite</string>
<string name="three_pid_revoke_invite_dialog_content">Revoke invite to %1$s?</string>