diff --git a/CHANGES.md b/CHANGES.md index 80d606f57e..936e6b0ffe 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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 🐛: diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/identity/IdentityService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/identity/IdentityService.kt index 537104a084..aedb813735 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/identity/IdentityService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/identity/IdentityService.kt @@ -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, callback: MatrixCallback>): 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 diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/identity/IdentityServiceError.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/identity/IdentityServiceError.kt index 72bb72cc2c..42fdb97643 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/identity/IdentityServiceError.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/identity/IdentityServiceError.kt @@ -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() } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/DefaultIdentityService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/DefaultIdentityService.kt index 20f8b7f868..c6fb34151c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/DefaultIdentityService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/DefaultIdentityService.kt @@ -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, callback: MatrixCallback>): 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, callback: MatrixCallback>): 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 diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/IdentityModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/IdentityModule.kt index e140cc19f3..7a39a333a5 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/IdentityModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/IdentityModule.kt @@ -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() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/data/IdentityData.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/data/IdentityData.kt index 0f04f2fe1a..54d35b34fa 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/data/IdentityData.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/data/IdentityData.kt @@ -20,5 +20,6 @@ internal data class IdentityData( val identityServerUrl: String?, val token: String?, val hashLookupPepper: String?, - val hashLookupAlgorithm: List + val hashLookupAlgorithm: List, + val userConsent: Boolean ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/data/IdentityStore.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/data/IdentityStore.kt index 3a905833d5..0e05224be5 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/data/IdentityStore.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/data/IdentityStore.kt @@ -27,6 +27,8 @@ internal interface IdentityStore { fun setToken(token: String?) + fun setUserConsent(consent: Boolean) + fun setHashDetails(hashDetailResponse: IdentityHashDetailResponse) /** diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/db/IdentityDataEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/db/IdentityDataEntity.kt index cc03465cc8..019289a884 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/db/IdentityDataEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/db/IdentityDataEntity.kt @@ -23,7 +23,8 @@ internal open class IdentityDataEntity( var identityServerUrl: String? = null, var token: String? = null, var hashLookupPepper: String? = null, - var hashLookupAlgorithm: RealmList = RealmList() + var hashLookupAlgorithm: RealmList = RealmList(), + var userConsent: Boolean = false ) : RealmObject() { companion object diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/db/IdentityDataEntityQuery.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/db/IdentityDataEntityQuery.kt index 062c28ea55..5152e33743 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/db/IdentityDataEntityQuery.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/db/IdentityDataEntityQuery.kt @@ -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) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/db/IdentityMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/db/IdentityMapper.kt index 98207f1b38..bf23c05811 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/db/IdentityMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/db/IdentityMapper.kt @@ -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 ) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/db/RealmIdentityStore.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/db/RealmIdentityStore.kt index 0352e9b936..2fa3fc0cfb 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/db/RealmIdentityStore.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/db/RealmIdentityStore.kt @@ -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 -> diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/db/RealmIdentityStoreMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/db/RealmIdentityStoreMigration.kt new file mode 100644 index 0000000000..6081dbab12 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/db/RealmIdentityStoreMigration.kt @@ -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) + } +} diff --git a/vector/src/main/java/im/vector/app/core/error/ErrorFormatter.kt b/vector/src/main/java/im/vector/app/core/error/ErrorFormatter.kt index 6065c74541..b9bc935890 100644 --- a/vector/src/main/java/im/vector/app/core/error/ErrorFormatter.kt +++ b/vector/src/main/java/im/vector/app/core/error/ErrorFormatter.kt @@ -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 }) } } diff --git a/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookAction.kt b/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookAction.kt index 8eb5bc733b..e380998fd2 100644 --- a/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookAction.kt +++ b/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookAction.kt @@ -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() } diff --git a/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookController.kt b/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookController.kt index 9eca2afa60..59c23f4ac7 100644 --- a/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookController.kt +++ b/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookController.kt @@ -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, - 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) } } diff --git a/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookFragment.kt b/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookFragment.kt index c4cf9eab39..23d21f5240 100644 --- a/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookFragment.kt +++ b/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookFragment.kt @@ -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) } diff --git a/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookViewModel.kt b/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookViewModel.kt index 167660d11e..2c4c5d0596 100644 --- a/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookViewModel.kt @@ -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) { + 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> { 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) { @@ -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( diff --git a/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookViewState.kt b/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookViewState.kt index 3e4f4ddcb6..d2ee684c4d 100644 --- a/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookViewState.kt +++ b/vector/src/main/java/im/vector/app/features/contactsbook/ContactsBookViewState.kt @@ -26,10 +26,14 @@ data class ContactsBookViewState( val mappedContacts: Async> = 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 = 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 diff --git a/vector/src/main/java/im/vector/app/features/discovery/DiscoverySettingsAction.kt b/vector/src/main/java/im/vector/app/features/discovery/DiscoverySettingsAction.kt index c66ae69e6a..426f1321e7 100644 --- a/vector/src/main/java/im/vector/app/features/discovery/DiscoverySettingsAction.kt +++ b/vector/src/main/java/im/vector/app/features/discovery/DiscoverySettingsAction.kt @@ -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() diff --git a/vector/src/main/java/im/vector/app/features/discovery/DiscoverySettingsController.kt b/vector/src/main/java/im/vector/app/features/discovery/DiscoverySettingsController.kt index 306d9bffd1..55c11f3a50 100644 --- a/vector/src/main/java/im/vector/app/features/discovery/DiscoverySettingsController.kt +++ b/vector/src/main/java/im/vector/app/features/discovery/DiscoverySettingsController.kt @@ -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() } } diff --git a/vector/src/main/java/im/vector/app/features/discovery/DiscoverySettingsFragment.kt b/vector/src/main/java/im/vector/app/features/discovery/DiscoverySettingsFragment.kt index bfbc00b15a..97d824054d 100644 --- a/vector/src/main/java/im/vector/app/features/discovery/DiscoverySettingsFragment.kt +++ b/vector/src/main/java/im/vector/app/features/discovery/DiscoverySettingsFragment.kt @@ -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) } diff --git a/vector/src/main/java/im/vector/app/features/discovery/DiscoverySettingsState.kt b/vector/src/main/java/im/vector/app/features/discovery/DiscoverySettingsState.kt index 6b28c07e89..21fbcf1ca7 100644 --- a/vector/src/main/java/im/vector/app/features/discovery/DiscoverySettingsState.kt +++ b/vector/src/main/java/im/vector/app/features/discovery/DiscoverySettingsState.kt @@ -25,5 +25,6 @@ data class DiscoverySettingsState( val emailList: Async> = Uninitialized, val phoneNumbersList: Async> = Uninitialized, // Can be true if terms are updated - val termsNotSigned: Boolean = false + val termsNotSigned: Boolean = false, + val userConsent: Boolean = false ) : MvRxState diff --git a/vector/src/main/java/im/vector/app/features/discovery/DiscoverySettingsViewModel.kt b/vector/src/main/java/im/vector/app/features/discovery/DiscoverySettingsViewModel.kt index 0bfcdd9984..0f294e080a 100644 --- a/vector/src/main/java/im/vector/app/features/discovery/DiscoverySettingsViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/discovery/DiscoverySettingsViewModel.kt @@ -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 { 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 { 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)) } diff --git a/vector/src/main/res/layout/fragment_contacts_book.xml b/vector/src/main/res/layout/fragment_contacts_book.xml index eb90da1bbe..1f8566e05e 100644 --- a/vector/src/main/res/layout/fragment_contacts_book.xml +++ b/vector/src/main/res/layout/fragment_contacts_book.xml @@ -93,6 +93,27 @@ app:layout_constraintTop_toBottomOf="@+id/phoneBookFilterContainer" tools:visibility="visible" /> + + + + + app:layout_constraintTop_toBottomOf="@+id/phoneBookBottomBarrier" /> We sent you a confirm email to %s, check your email and click on the confirmation link We sent you a confirm email to %s, please first check your email and click on the confirmation link Pending + Send emails and phone numbers + You have given your consent to send emails and phone numbers to this identity server to discover other users from your contacts. + You have not given your consent to send emails and phone numbers to this identity server to discover other users from your contacts. + Revoke my consent + Give consent + + Send emails and phone numbers + 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. Enter an identity server URL Could not connect to identity server @@ -2527,6 +2535,7 @@ For your privacy, Element only supports sending hashed user emails and phone number. The association has failed. The is no current association with this identifier. + The user consent has not been provided. Your homeserver (%1$s) proposes to use %2$s for your identity server Use %1$s @@ -2593,6 +2602,7 @@ Retrieving your contacts… Your contact book is empty Contacts book + Search for contacts on Matrix Revoke invite Revoke invite to %1$s?