Merge pull request #4587 from vector-im/feature/bma/is_consent

Iterate on user consent dialog for identity server
This commit is contained in:
Benoit Marty 2021-11-30 18:22:21 +01:00 committed by GitHub
commit 3f39c5dae1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 208 additions and 67 deletions

1
changelog.d/4577.feature Normal file
View File

@ -0,0 +1 @@
Iterate on the consent dialog of the identity server.

View File

@ -17,10 +17,15 @@
package im.vector.app.core.utils
import android.content.Context
import android.text.method.LinkMovementMethod
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.TextView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import im.vector.app.R
import im.vector.app.features.discovery.IdentityServerWithTerms
import me.gujun.android.span.link
import me.gujun.android.span.span
/**
* Open a web view above the current activity.
@ -40,16 +45,36 @@ fun Context.displayInWebView(url: String) {
.show()
}
fun Context.showIdentityServerConsentDialog(configuredIdentityServer: String?, policyLinkCallback: () -> Unit, consentCallBack: (() -> Unit)) {
fun Context.showIdentityServerConsentDialog(identityServerWithTerms: IdentityServerWithTerms?,
consentCallBack: (() -> Unit)) {
// Build the message
val content = span {
+getString(R.string.identity_server_consent_dialog_content_3)
+"\n\n"
if (identityServerWithTerms?.policies?.isNullOrEmpty() == false) {
span {
textStyle = "bold"
text = getString(R.string.settings_privacy_policy)
}
identityServerWithTerms.policies.forEach {
+"\n"
// Use the url as the text too
link(it.url, it.url)
}
+"\n\n"
}
+getString(R.string.identity_server_consent_dialog_content_question)
}
MaterialAlertDialogBuilder(this)
.setTitle(getString(R.string.identity_server_consent_dialog_title_2, configuredIdentityServer ?: ""))
.setMessage(R.string.identity_server_consent_dialog_content_2)
.setPositiveButton(R.string.yes) { _, _ ->
.setTitle(getString(R.string.identity_server_consent_dialog_title_2, identityServerWithTerms?.serverUrl.orEmpty()))
.setMessage(content)
.setPositiveButton(R.string.reactions_agree) { _, _ ->
consentCallBack.invoke()
}
.setNeutralButton(R.string.identity_server_consent_dialog_neutral_policy) { _, _ ->
policyLinkCallback.invoke()
}
.setNegativeButton(R.string.no, null)
.setNegativeButton(R.string.action_not_now, null)
.show()
.apply {
// Make the link(s) clickable. Must be called after show()
(findViewById(android.R.id.message) as? TextView)?.movementMethod = LinkMovementMethod.getInstance()
}
}

View File

@ -21,5 +21,6 @@ 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 UserConsentRequest : ContactsBookAction()
object UserConsentGranted : ContactsBookAction()
}

View File

@ -26,11 +26,11 @@ import com.airbnb.mvrx.activityViewModel
import com.airbnb.mvrx.withState
import im.vector.app.core.extensions.cleanup
import im.vector.app.core.extensions.configureWith
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.extensions.hideKeyboard
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.utils.showIdentityServerConsentDialog
import im.vector.app.databinding.FragmentContactsBookBinding
import im.vector.app.features.navigation.SettingsActivityPayload
import im.vector.app.features.userdirectory.PendingSelection
import im.vector.app.features.userdirectory.UserListAction
import im.vector.app.features.userdirectory.UserListSharedAction
@ -68,22 +68,27 @@ class ContactsBookFragment @Inject constructor(
setupConsentView()
setupOnlyBoundContactsView()
setupCloseView()
contactsBookViewModel.observeViewEvents {
when (it) {
is ContactsBookViewEvents.Failure -> showFailure(it.throwable)
is ContactsBookViewEvents.OnPoliciesRetrieved -> showConsentDialog(it)
}.exhaustive
}
}
private fun setupConsentView() {
views.phoneBookSearchForMatrixContacts.setOnClickListener {
withState(contactsBookViewModel) { state ->
requireContext().showIdentityServerConsentDialog(
state.identityServerUrl,
policyLinkCallback = {
navigator.openSettings(requireContext(), SettingsActivityPayload.DiscoverySettings(expandIdentityPolicies = true))
},
consentCallBack = { contactsBookViewModel.handle(ContactsBookAction.UserConsentGranted) }
)
}
contactsBookViewModel.handle(ContactsBookAction.UserConsentRequest)
}
}
private fun showConsentDialog(event: ContactsBookViewEvents.OnPoliciesRetrieved) {
requireContext().showIdentityServerConsentDialog(
event.identityServerWithTerms,
consentCallBack = { contactsBookViewModel.handle(ContactsBookAction.UserConsentGranted) }
)
}
private fun setupOnlyBoundContactsView() {
views.phoneBookOnlyBoundContacts.checkedChanges()
.onEach {

View File

@ -0,0 +1,25 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.contactsbook
import im.vector.app.core.platform.VectorViewEvents
import im.vector.app.features.discovery.IdentityServerWithTerms
sealed class ContactsBookViewEvents : VectorViewEvents {
data class Failure(val throwable: Throwable) : ContactsBookViewEvents()
data class OnPoliciesRetrieved(val identityServerWithTerms: IdentityServerWithTerms?) : ContactsBookViewEvents()
}

View File

@ -16,20 +16,21 @@
package im.vector.app.features.contactsbook
import androidx.lifecycle.viewModelScope
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MavericksViewModelFactory
import com.airbnb.mvrx.Success
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.R
import im.vector.app.core.contacts.ContactsDataSource
import im.vector.app.core.contacts.MappedContact
import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.EmptyViewEvents
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.core.resources.StringProvider
import im.vector.app.features.discovery.fetchIdentityServerWithTerms
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.session.Session
@ -37,11 +38,12 @@ import org.matrix.android.sdk.api.session.identity.IdentityServiceError
import org.matrix.android.sdk.api.session.identity.ThreePid
import timber.log.Timber
class ContactsBookViewModel @AssistedInject constructor(@Assisted
initialState: ContactsBookViewState,
private val contactsDataSource: ContactsDataSource,
private val session: Session) :
VectorViewModel<ContactsBookViewState, ContactsBookAction, EmptyViewEvents>(initialState) {
class ContactsBookViewModel @AssistedInject constructor(
@Assisted initialState: ContactsBookViewState,
private val contactsDataSource: ContactsDataSource,
private val stringProvider: StringProvider,
private val session: Session
) : VectorViewModel<ContactsBookViewState, ContactsBookAction, ContactsBookViewEvents>(initialState) {
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<ContactsBookViewModel, ContactsBookViewState> {
@ -162,9 +164,22 @@ class ContactsBookViewModel @AssistedInject constructor(@Assisted
is ContactsBookAction.FilterWith -> handleFilterWith(action)
is ContactsBookAction.OnlyBoundContacts -> handleOnlyBoundContacts(action)
ContactsBookAction.UserConsentGranted -> handleUserConsentGranted()
ContactsBookAction.UserConsentRequest -> handleUserConsentRequest()
}.exhaustive
}
private fun handleUserConsentRequest() {
viewModelScope.launch {
val event = try {
val result = session.fetchIdentityServerWithTerms(stringProvider.getString(R.string.resources_language))
ContactsBookViewEvents.OnPoliciesRetrieved(result)
} catch (throwable: Throwable) {
ContactsBookViewEvents.Failure(throwable)
}
_viewEvents.post(event)
}
}
private fun handleUserConsentGranted() {
session.identityService().setUserConsent(true)

View File

@ -186,8 +186,7 @@ class DiscoverySettingsFragment @Inject constructor(
if (newValue) {
withState(viewModel) { state ->
requireContext().showIdentityServerConsentDialog(
state.identityServer.invoke()?.serverUrl,
policyLinkCallback = { viewModel.handle(DiscoverySettingsAction.SetPoliciesExpandState(expanded = true)) },
state.identityServer.invoke(),
consentCallBack = { viewModel.handle(DiscoverySettingsAction.UpdateUserConsent(true)) }
)
}

View File

@ -29,10 +29,3 @@ data class DiscoverySettingsState(
val userConsent: Boolean = false,
val isIdentityPolicyUrlsExpanded: Boolean = false
) : MavericksState
data class IdentityServerWithTerms(
val serverUrl: String,
val policies: List<IdentityServerPolicy>
)
data class IdentityServerPolicy(val name: String, val url: String)

View File

@ -30,7 +30,6 @@ import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.core.resources.StringProvider
import im.vector.app.core.utils.ensureProtocol
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@ -39,7 +38,6 @@ import org.matrix.android.sdk.api.session.identity.IdentityServiceError
import org.matrix.android.sdk.api.session.identity.IdentityServiceListener
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.terms.TermsService
import org.matrix.android.sdk.flow.flow
class DiscoverySettingsViewModel @AssistedInject constructor(
@ -56,7 +54,6 @@ class DiscoverySettingsViewModel @AssistedInject constructor(
companion object : MavericksViewModelFactory<DiscoverySettingsViewModel, DiscoverySettingsState> by hiltMavericksViewModelFactory()
private val identityService = session.identityService()
private val termsService: TermsService = session
private val identityServerManagerListener = object : IdentityServiceListener {
override fun onIdentityServerChange() = withState { state ->
@ -397,7 +394,7 @@ class DiscoverySettingsViewModel @AssistedInject constructor(
}
}
viewModelScope.launch {
runCatching { fetchIdentityServerWithTerms() }.fold(
runCatching { session.fetchIdentityServerWithTerms(stringProvider.getString(R.string.resources_language)) }.fold(
onSuccess = { setState { copy(identityServer = Success(it)) } },
onFailure = { _viewEvents.post(DiscoverySettingsViewEvents.Failure(it)) }
)
@ -405,21 +402,6 @@ class DiscoverySettingsViewModel @AssistedInject constructor(
}
private suspend fun fetchIdentityServerWithTerms(): IdentityServerWithTerms? {
val identityServerUrl = identityService.getCurrentIdentityServerUrl()
return identityServerUrl?.let {
val terms = termsService.getTerms(TermsService.ServiceType.IdentityService, identityServerUrl.ensureProtocol())
.serverResponse
.getLocalizedTerms(stringProvider.getString(R.string.resources_language))
val policyUrls = terms.mapNotNull {
val name = it.localizedName ?: it.policyName
val url = it.localizedUrl
if (name == null || url == null) {
null
} else {
IdentityServerPolicy(name = name, url = url)
}
}
IdentityServerWithTerms(identityServerUrl, policyUrls)
}
return session.fetchIdentityServerWithTerms(stringProvider.getString(R.string.resources_language))
}
}

View File

@ -0,0 +1,40 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.discovery
import im.vector.app.core.utils.ensureProtocol
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.terms.TermsService
suspend fun Session.fetchIdentityServerWithTerms(userLanguage: String): IdentityServerWithTerms? {
val identityServerUrl = identityService().getCurrentIdentityServerUrl()
return identityServerUrl?.let {
val terms = getTerms(TermsService.ServiceType.IdentityService, identityServerUrl.ensureProtocol())
.serverResponse
.getLocalizedTerms(userLanguage)
val policyUrls = terms.mapNotNull {
val name = it.localizedName ?: it.policyName
val url = it.localizedUrl
if (name == null || url == null) {
null
} else {
IdentityServerPolicy(name = name, url = url)
}
}
IdentityServerWithTerms(identityServerUrl, policyUrls)
}
}

View File

@ -0,0 +1,27 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.discovery
data class IdentityServerWithTerms(
val serverUrl: String,
val policies: List<IdentityServerPolicy>
)
data class IdentityServerPolicy(
val name: String,
val url: String
)

View File

@ -24,6 +24,7 @@ sealed class UserListAction : VectorViewModelAction {
data class AddPendingSelection(val pendingSelection: PendingSelection) : UserListAction()
data class RemovePendingSelection(val pendingSelection: PendingSelection) : UserListAction()
object ComputeMatrixToLinkForSharing : UserListAction()
object UserConsentRequest : UserListAction()
data class UpdateUserConsent(val consent: Boolean) : UserListAction()
object Resumed : UserListAction()
}

View File

@ -42,7 +42,6 @@ import im.vector.app.core.utils.showIdentityServerConsentDialog
import im.vector.app.core.utils.startSharePlainTextIntent
import im.vector.app.databinding.FragmentUserListBinding
import im.vector.app.features.homeserver.HomeServerCapabilitiesViewModel
import im.vector.app.features.navigation.SettingsActivityPayload
import im.vector.app.features.settings.VectorSettingsActivity
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@ -103,6 +102,8 @@ class UserListFragment @Inject constructor(
extraTitle = getString(R.string.invite_friends_rich_title)
)
}
is UserListViewEvents.Failure -> showFailure(it.throwable)
is UserListViewEvents.OnPoliciesRetrieved -> showConsentDialog(it)
}
}
}
@ -231,15 +232,14 @@ class UserListFragment @Inject constructor(
}
override fun giveIdentityServerConsent() {
withState(viewModel) { state ->
requireContext().showIdentityServerConsentDialog(
state.configuredIdentityServer,
policyLinkCallback = {
navigator.openSettings(requireContext(), SettingsActivityPayload.DiscoverySettings(expandIdentityPolicies = true))
},
consentCallBack = { viewModel.handle(UserListAction.UpdateUserConsent(true)) }
)
}
viewModel.handle(UserListAction.UserConsentRequest)
}
private fun showConsentDialog(event: UserListViewEvents.OnPoliciesRetrieved) {
requireContext().showIdentityServerConsentDialog(
event.identityServerWithTerms,
consentCallBack = { viewModel.handle(UserListAction.UpdateUserConsent(true)) }
)
}
override fun onUseQRCode() {

View File

@ -17,10 +17,13 @@
package im.vector.app.features.userdirectory
import im.vector.app.core.platform.VectorViewEvents
import im.vector.app.features.discovery.IdentityServerWithTerms
/**
* Transient events for invite users to room screen
*/
sealed class UserListViewEvents : VectorViewEvents {
data class Failure(val throwable: Throwable) : UserListViewEvents()
data class OnPoliciesRetrieved(val identityServerWithTerms: IdentityServerWithTerms?) : UserListViewEvents()
data class OpenShareMatrixToLink(val link: String) : UserListViewEvents()
}

View File

@ -23,12 +23,15 @@ import com.airbnb.mvrx.Uninitialized
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.R
import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.extensions.isEmail
import im.vector.app.core.extensions.toggle
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.core.resources.StringProvider
import im.vector.app.features.discovery.fetchIdentityServerWithTerms
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filter
@ -36,6 +39,7 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.MatrixPatterns
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.session.Session
@ -51,9 +55,11 @@ data class ThreePidUser(
val user: User?
)
class UserListViewModel @AssistedInject constructor(@Assisted initialState: UserListViewState,
private val session: Session) :
VectorViewModel<UserListViewState, UserListAction, UserListViewEvents>(initialState) {
class UserListViewModel @AssistedInject constructor(
@Assisted initialState: UserListViewState,
private val stringProvider: StringProvider,
private val session: Session
) : VectorViewModel<UserListViewState, UserListAction, UserListViewEvents>(initialState) {
private val knownUsersSearch = MutableStateFlow("")
private val directoryUsersSearch = MutableStateFlow("")
@ -104,11 +110,24 @@ class UserListViewModel @AssistedInject constructor(@Assisted initialState: User
is UserListAction.AddPendingSelection -> handleSelectUser(action)
is UserListAction.RemovePendingSelection -> handleRemoveSelectedUser(action)
UserListAction.ComputeMatrixToLinkForSharing -> handleShareMyMatrixToLink()
UserListAction.UserConsentRequest -> handleUserConsentRequest()
is UserListAction.UpdateUserConsent -> handleISUpdateConsent(action)
UserListAction.Resumed -> handleResumed()
}.exhaustive
}
private fun handleUserConsentRequest() {
viewModelScope.launch {
val event = try {
val result = session.fetchIdentityServerWithTerms(stringProvider.getString(R.string.resources_language))
UserListViewEvents.OnPoliciesRetrieved(result)
} catch (throwable: Throwable) {
UserListViewEvents.Failure(throwable)
}
_viewEvents.post(event)
}
}
private fun handleISUpdateConsent(action: UserListAction.UpdateUserConsent) {
session.identityService().setUserConsent(action.consent)
withState {

View File

@ -456,6 +456,7 @@
<string name="copied_to_clipboard">Copied to clipboard</string>
<string name="disable">Disable</string>
<string name="action_return">Return</string>
<string name="action_not_now">Not now</string>
<!-- dialog titles -->
<string name="dialog_title_confirmation">Confirmation</string>
@ -2193,7 +2194,9 @@
<string name="room_list_rooms_empty_body">Your rooms will be displayed here. Tap the + bottom right to find existing ones or start some of your own.</string>
<string name="title_activity_emoji_reaction_picker">Reactions</string>
<!-- TODO weblate sync: rename to "action_agree"-->
<string name="reactions_agree">Agree</string>
<!-- TODO weblate sync: rename to "action_like"-->
<string name="reactions_like">Like</string>
<string name="message_add_reaction">Add Reaction</string>
<string name="message_view_reaction">View Reactions</string>
@ -2377,6 +2380,8 @@
<string name="identity_server_consent_dialog_title_2">Send emails and phone numbers to %s</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="identity_server_consent_dialog_content_2">To discover existing contacts, you need to send contact info to your identity server.\n\nWe hash your data before sending for privacy. Do you consent to send this info?</string>
<string name="identity_server_consent_dialog_content_3">To discover existing contacts, you need to send contact info (emails and phone numbers) to your identity server. We hash your data before sending for privacy.</string>
<string name="identity_server_consent_dialog_content_question">Do you agree to send this info?</string>
<string name="identity_server_consent_dialog_neutral_policy">Policy</string>
<string name="settings_discovery_enter_identity_server">Enter an identity server URL</string>