diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/identity/IdentityService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/identity/IdentityService.kt index 0a844d0921..668cae5e00 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/identity/IdentityService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/identity/IdentityService.kt @@ -27,7 +27,7 @@ interface IdentityService { /** * Return the default identity server of the homeserver (using Wellknown request) */ - fun getDefaultIdentityServer(): String? + fun getDefaultIdentityServer(callback: MatrixCallback): Cancelable fun getCurrentIdentityServer(): String? @@ -35,9 +35,11 @@ interface IdentityService { fun disconnect() - fun bindThreePid() + fun startBindSession(threePid: ThreePid, nothing: Nothing?, matrixCallback: MatrixCallback) + fun finalizeBindSessionFor3PID(threePid: ThreePid, matrixCallback: MatrixCallback) + fun submitValidationToken(pid: ThreePid, code: String, matrixCallback: MatrixCallback) - fun unbindThreePid() + fun startUnBindSession(threePid: ThreePid, nothing: Nothing?, matrixCallback: MatrixCallback>) fun lookUp(threePids: List, callback: MatrixCallback>): Cancelable diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/identity/ThreePid.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/identity/ThreePid.kt index 7aabb2b9e0..2fa97492fd 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/identity/ThreePid.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/identity/ThreePid.kt @@ -18,5 +18,5 @@ package im.vector.matrix.android.api.session.identity sealed class ThreePid(open val value: String) { data class Email(val email: String) : ThreePid(email) - data class Msisdn(val msisdn: String) : ThreePid(msisdn) + data class Msisdn(val msisdn: String, val countryCode: String? = null) : ThreePid(msisdn) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/identity/DefaultIdentityService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/identity/DefaultIdentityService.kt index 01f2c466ac..5a4a66a722 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/identity/DefaultIdentityService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/identity/DefaultIdentityService.kt @@ -30,6 +30,7 @@ import im.vector.matrix.android.api.session.identity.IdentityServiceError import im.vector.matrix.android.api.session.identity.IdentityServiceListener import im.vector.matrix.android.api.session.identity.ThreePid import im.vector.matrix.android.api.util.Cancelable +import im.vector.matrix.android.api.util.NoOpCancellable import im.vector.matrix.android.internal.di.AuthenticatedIdentity import im.vector.matrix.android.internal.di.Unauthenticated import im.vector.matrix.android.internal.network.RetrofitFactory @@ -78,7 +79,7 @@ internal class DefaultIdentityService @Inject constructor( lifecycleRegistry.currentState = Lifecycle.State.STARTED // Observe the account data change accountDataDataSource - .getLiveAccountDataEvent(UserAccountData.TYPE_IDENTITY) + .getLiveAccountDataEvent(UserAccountData.TYPE_IDENTITY_SERVER) .observeNotNull(lifecycleOwner) { val identityServerContent = it.getOrNull()?.content?.toModel() if (identityServerContent != null) { @@ -104,8 +105,10 @@ internal class DefaultIdentityService @Inject constructor( lifecycleRegistry.currentState = Lifecycle.State.DESTROYED } - override fun getDefaultIdentityServer(): String? { - TODO("Not yet implemented") + override fun getDefaultIdentityServer(callback: MatrixCallback): Cancelable { + // TODO Use Wellknown request + callback.onSuccess("https://vector.im") + return NoOpCancellable } override fun getCurrentIdentityServer(): String? { @@ -116,22 +119,49 @@ internal class DefaultIdentityService @Inject constructor( TODO("Not yet implemented") } + override fun startBindSession(threePid: ThreePid, nothing: Nothing?, matrixCallback: MatrixCallback) { + TODO("Not yet implemented") + } + + override fun finalizeBindSessionFor3PID(threePid: ThreePid, matrixCallback: MatrixCallback) { + TODO("Not yet implemented") + } + + override fun submitValidationToken(pid: ThreePid, code: String, matrixCallback: MatrixCallback) { + TODO("Not yet implemented") + } + + override fun startUnBindSession(threePid: ThreePid, nothing: Nothing?, matrixCallback: MatrixCallback>) { + TODO("Not yet implemented") + } + override fun setNewIdentityServer(url: String?, callback: MatrixCallback): Cancelable { + val urlCandidate = url?.let { param -> + buildString { + if (!param.startsWith("http")) { + append("https://") + } + append(param) + } + } + return GlobalScope.launchToCallback(coroutineDispatchers.main, callback) { val current = getCurrentIdentityServer() - when (url) { + when (urlCandidate) { current -> // Nothing to do Timber.d("Same URL, nothing to do") null -> { - // TODO - // Disconnect previous one if any + // TODO Disconnect previous one if any identityServiceStore.setUrl(null) updateAccountData(null) } else -> { // TODO: check first that it is a valid identity server - updateAccountData(url) + // Try to get a token + getIdentityServerToken(urlCandidate) + + updateAccountData(urlCandidate) } } } @@ -143,14 +173,6 @@ internal class DefaultIdentityService @Inject constructor( )) } - override fun bindThreePid() { - TODO("Not yet implemented") - } - - override fun unbindThreePid() { - TODO("Not yet implemented") - } - override fun lookUp(threePids: List, callback: MatrixCallback>): Cancelable { return GlobalScope.launchToCallback(coroutineDispatchers.main, callback) { lookUpInternal(true, threePids) @@ -181,15 +203,19 @@ internal class DefaultIdentityService @Inject constructor( if (entity.token == null) { // Try to get a token - val openIdToken = openIdTokenTask.execute(Unit) - - val api = retrofitFactory.create(unauthenticatedOkHttpClient, url).create(IdentityAuthAPI::class.java) - val token = identityRegisterTask.execute(IdentityRegisterTask.Params(api, openIdToken)) - - identityServiceStore.setToken(token.token) + getIdentityServerToken(url) } } + private suspend fun getIdentityServerToken(url: String) { + val api = retrofitFactory.create(unauthenticatedOkHttpClient, url).create(IdentityAuthAPI::class.java) + + val openIdToken = openIdTokenTask.execute(Unit) + val token = identityRegisterTask.execute(IdentityRegisterTask.Params(api, openIdToken)) + + identityServiceStore.setToken(token.token) + } + override fun addListener(listener: IdentityServiceListener) { listeners.add(listener) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/model/accountdata/UserAccountData.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/model/accountdata/UserAccountData.kt index e7a7939540..ce46d3ba77 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/model/accountdata/UserAccountData.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/model/accountdata/UserAccountData.kt @@ -30,6 +30,6 @@ abstract class UserAccountData : AccountDataContent { const val TYPE_PREVIEW_URLS = "org.matrix.preview_urls" const val TYPE_WIDGETS = "m.widgets" const val TYPE_PUSH_RULES = "m.push_rules" - const val TYPE_IDENTITY = "m.identity" + const val TYPE_IDENTITY_SERVER = "m.identity_server" } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/model/accountdata/UserAccountDataIdentity.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/model/accountdata/UserAccountDataIdentity.kt index 4777daf591..354022420e 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/model/accountdata/UserAccountDataIdentity.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/model/accountdata/UserAccountDataIdentity.kt @@ -21,7 +21,7 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) internal data class UserAccountDataIdentity( - @Json(name = "type") override val type: String = TYPE_IDENTITY, + @Json(name = "type") override val type: String = TYPE_IDENTITY_SERVER, @Json(name = "content") val content: IdentityContent? = null ) : UserAccountData() diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/accountdata/UpdateUserAccountDataTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/accountdata/UpdateUserAccountDataTask.kt index 7daeef699e..01dc297946 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/accountdata/UpdateUserAccountDataTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/user/accountdata/UpdateUserAccountDataTask.kt @@ -32,7 +32,7 @@ internal interface UpdateUserAccountDataTask : Task() { + + var listener: Listener? = null + + override fun buildModels(data: DiscoverySettingsState) { + when (data.identityServer) { + is Loading -> { + settingsLoadingItem { + id("identityServerLoading") + } + } + is Fail -> { + settingsInfoItem { + id("identityServerError") + helperText(data.identityServer.error.message) + } + } + is Success -> { + buildIdentityServerSection(data) + + val hasIdentityServer = data.identityServer().isNullOrBlank().not() + + if (hasIdentityServer) { + buildMailSection(data) + buildPhoneNumberSection(data) + } + } + } + } + + private fun buildPhoneNumberSection(data: DiscoverySettingsState) { + settingsSectionTitle { + id("msisdn") + titleResId(R.string.settings_discovery_msisdn_title) + } + + when (data.phoneNumbersList) { + is Loading -> { + settingsLoadingItem { + id("phoneLoading") + } + } + is Fail -> { + settingsInfoItem { + id("msisdnListError") + helperText(data.phoneNumbersList.error.message) + } + } + is Success -> { + val phones = data.phoneNumbersList.invoke() + if (phones.isEmpty()) { + settingsInfoItem { + id("no_msisdn") + helperText(stringProvider.getString(R.string.settings_discovery_no_msisdn)) + } + } else { + phones.forEach { piState -> + val phoneNumber = PhoneNumberUtil.getInstance() + .parse("+${piState.value}", null) + ?.let { + PhoneNumberUtil.getInstance().format(it, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL) + } + + settingsTextButtonItem { + id(piState.value) + title(phoneNumber) + colorProvider(colorProvider) + stringProvider(stringProvider) + when { + piState.isShared is Loading -> buttonIndeterminate(true) + piState.isShared is Fail -> { + buttonType(SettingsTextButtonItem.ButtonType.NORMAL) + buttonStyle(SettingsTextButtonItem.ButtonStyle.DESTRUCTIVE) + buttonTitle(stringProvider.getString(R.string.global_retry)) + infoMessage(piState.isShared.error.message) + buttonClickListener(View.OnClickListener { + listener?.onTapRetryToRetrieveBindings() + }) + } + piState.isShared is Success -> when (piState.isShared()) { + PidInfo.SharedState.SHARED, + PidInfo.SharedState.NOT_SHARED -> { + checked(piState.isShared() == PidInfo.SharedState.SHARED) + buttonType(SettingsTextButtonItem.ButtonType.SWITCH) + switchChangeListener { _, checked -> + if (checked) { + listener?.onTapShareMsisdn(piState.value) + } else { + listener?.onTapRevokeMsisdn(piState.value) + } + } + } + PidInfo.SharedState.NOT_VERIFIED_FOR_BIND, + PidInfo.SharedState.NOT_VERIFIED_FOR_UNBIND -> { + buttonType(SettingsTextButtonItem.ButtonType.NORMAL) + buttonTitle("") + } + } + } + } + when (piState.isShared()) { + PidInfo.SharedState.NOT_VERIFIED_FOR_BIND, + PidInfo.SharedState.NOT_VERIFIED_FOR_UNBIND -> { + settingsItemText { + id("tverif" + piState.value) + descriptionText(stringProvider.getString(R.string.settings_text_message_sent, phoneNumber)) + interactionListener(object : SettingsItemText.Listener { + override fun onValidate(code: String) { + val bind = piState.isShared() == PidInfo.SharedState.NOT_VERIFIED_FOR_BIND + listener?.checkMsisdnVerification(piState.value, code, bind) + } + }) + } + } + else -> { + } + } + } + } + } + } + } + + private fun buildMailSection(data: DiscoverySettingsState) { + settingsSectionTitle { + id("emails") + titleResId(R.string.settings_discovery_emails_title) + } + when (data.emailList) { + is Loading -> { + settingsLoadingItem { + id("mailLoading") + } + } + is Fail -> { + settingsInfoItem { + id("mailListError") + helperText(data.emailList.error.message) + } + } + is Success -> { + val emails = data.emailList.invoke() + if (emails.isEmpty()) { + settingsInfoItem { + id("no_emails") + helperText(stringProvider.getString(R.string.settings_discovery_no_mails)) + } + } else { + emails.forEach { piState -> + settingsTextButtonItem { + id(piState.value) + title(piState.value) + colorProvider(colorProvider) + stringProvider(stringProvider) + when (piState.isShared) { + is Loading -> buttonIndeterminate(true) + is Fail -> { + buttonType(SettingsTextButtonItem.ButtonType.NORMAL) + buttonStyle(SettingsTextButtonItem.ButtonStyle.DESTRUCTIVE) + buttonTitle(stringProvider.getString(R.string.global_retry)) + infoMessage(piState.isShared.error.message) + buttonClickListener(View.OnClickListener { + listener?.onTapRetryToRetrieveBindings() + }) + } + is Success -> when (piState.isShared()) { + PidInfo.SharedState.SHARED, + PidInfo.SharedState.NOT_SHARED -> { + checked(piState.isShared() == PidInfo.SharedState.SHARED) + buttonType(SettingsTextButtonItem.ButtonType.SWITCH) + switchChangeListener { _, checked -> + if (checked) { + listener?.onTapShareEmail(piState.value) + } else { + listener?.onTapRevokeEmail(piState.value) + } + } + } + PidInfo.SharedState.NOT_VERIFIED_FOR_BIND, + PidInfo.SharedState.NOT_VERIFIED_FOR_UNBIND -> { + buttonType(SettingsTextButtonItem.ButtonType.NORMAL) + buttonTitleId(R.string._continue) + infoMessageTintColorId(R.color.vector_info_color) + infoMessage(stringProvider.getString(R.string.settings_discovery_confirm_mail, piState.value)) + buttonClickListener(View.OnClickListener { + val bind = piState.isShared() == PidInfo.SharedState.NOT_VERIFIED_FOR_BIND + listener?.checkEmailVerification(piState.value, bind) + }) + } + } + } + } + } + } + } + } + } + + private fun buildIdentityServerSection(data: DiscoverySettingsState) { + val identityServer = data.identityServer() ?: stringProvider.getString(R.string.none) + + settingsSectionTitle { + id("idsTitle") + titleResId(R.string.identity_server) + } + + settingsItem { + id("idServer") + description(identityServer) + } + + settingsInfoItem { + id("idServerFooter") + if (data.termsNotSigned) { + helperText(stringProvider.getString(R.string.settings_agree_to_terms, identityServer)) + showCompoundDrawable(true) + itemClickListener(View.OnClickListener { listener?.onSelectIdentityServer() }) + } else { + showCompoundDrawable(false) + if (data.identityServer() != null) { + helperText(stringProvider.getString(R.string.settings_discovery_identity_server_info, identityServer)) + } else { + helperTextResId(R.string.settings_discovery_identity_server_info_none) + } + } + } + + settingsButtonItem { + id("change") + colorProvider(colorProvider) + if (data.identityServer() != null) { + buttonTitleId(R.string.change_identity_server) + } else { + buttonTitleId(R.string.add_identity_server) + } + buttonStyle(SettingsTextButtonItem.ButtonStyle.POSITIVE) + buttonClickListener(View.OnClickListener { + listener?.onTapChangeIdentityServer() + }) + } + + if (data.identityServer() != null) { + settingsInfoItem { + id("removeInfo") + helperTextResId(R.string.settings_discovery_disconnect_identity_server_info) + } + settingsButtonItem { + id("remove") + colorProvider(colorProvider) + buttonTitleId(R.string.disconnect_identity_server) + buttonStyle(SettingsTextButtonItem.ButtonStyle.DESTRUCTIVE) + buttonClickListener(View.OnClickListener { + listener?.onTapDisconnectIdentityServer() + }) + } + } + } + + interface Listener { + fun onSelectIdentityServer() + fun onTapRevokeEmail(email: String) + fun onTapShareEmail(email: String) + fun checkEmailVerification(email: String, bind: Boolean) + fun checkMsisdnVerification(msisdn: String, code: String, bind: Boolean) + fun onTapRevokeMsisdn(msisdn: String) + fun onTapShareMsisdn(msisdn: String) + fun onTapChangeIdentityServer() + fun onTapDisconnectIdentityServer() + fun onTapRetryToRetrieveBindings() + } +} + diff --git a/vector/src/main/java/im/vector/riotx/features/discovery/DiscoverySettingsFragment.kt b/vector/src/main/java/im/vector/riotx/features/discovery/DiscoverySettingsFragment.kt new file mode 100644 index 0000000000..bdf0ca6de4 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/discovery/DiscoverySettingsFragment.kt @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.riotx.features.discovery + +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.appcompat.app.AlertDialog +import androidx.lifecycle.Observer +import com.airbnb.mvrx.fragmentViewModel +import com.airbnb.mvrx.withState +import im.vector.matrix.android.api.session.identity.ThreePid +import im.vector.riotx.R +import im.vector.riotx.core.extensions.cleanup +import im.vector.riotx.core.extensions.configureWith +import im.vector.riotx.core.extensions.exhaustive +import im.vector.riotx.core.platform.VectorBaseActivity +import im.vector.riotx.core.platform.VectorBaseFragment +import im.vector.riotx.features.discovery.change.SetIdentityServerFragment +import kotlinx.android.synthetic.main.fragment_generic_recycler.* +import javax.inject.Inject + +class DiscoverySettingsFragment @Inject constructor( + private val controller: DiscoverySettingsController, + val viewModelFactory: DiscoverySettingsViewModel.Factory +) : VectorBaseFragment(), DiscoverySettingsController.Listener { + + override fun getLayoutResId() = R.layout.fragment_generic_recycler + + private val viewModel by fragmentViewModel(DiscoverySettingsViewModel::class) + + lateinit var sharedViewModel: DiscoverySharedViewModel + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + sharedViewModel = activityViewModelProvider.get(DiscoverySharedViewModel::class.java) + + controller.listener = this + recyclerView.configureWith(controller) + + sharedViewModel.navigateEvent.observe(viewLifecycleOwner, Observer { + if (it.peekContent().first == DiscoverySharedViewModel.NEW_IDENTITY_SERVER_SET_REQUEST) { + viewModel.handle(DiscoverySettingsAction.ChangeIdentityServer(it.peekContent().second)) + } + }) + + viewModel.observeViewEvents { + when (it) { + is DiscoverySettingsViewEvents.Failure -> { + // TODO Snackbar.make(view, throwable.toString(), Snackbar.LENGTH_LONG).show() + } + }.exhaustive + } + } + + override fun onDestroyView() { + recyclerView.cleanup() + controller.listener = null + super.onDestroyView() + } + + override fun invalidate() = withState(viewModel) { state -> + controller.setData(state) + } + + override fun onResume() { + super.onResume() + (activity as? VectorBaseActivity)?.supportActionBar?.setTitle(R.string.settings_discovery_category) + + //If some 3pids are pending, we can try to check if they have been verified here + viewModel.handle(DiscoverySettingsAction.Refresh) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + /* TODO + if (requestCode == TERMS_REQUEST_CODE) { + if (Activity.RESULT_OK == resultCode) { + viewModel.refreshModel() + } else { + //add some error? + } + } + + */ + super.onActivityResult(requestCode, resultCode, data) + } + + override fun onSelectIdentityServer() = withState(viewModel) { state -> + if (state.termsNotSigned) { + /* + TODO + ReviewTermsActivity.intent(requireContext(), + TermsManager.ServiceType.IdentityService, + SetIdentityServerViewModel.sanitatizeBaseURL(state.identityServer() ?: ""), + null).also { + startActivityForResult(it, TERMS_REQUEST_CODE) + } + + */ + } + } + + override fun onTapRevokeEmail(email: String) { + viewModel.handle(DiscoverySettingsAction.RevokeThreePid(ThreePid.Email(email))) + } + + override fun onTapShareEmail(email: String) { + viewModel.handle(DiscoverySettingsAction.ShareThreePid(ThreePid.Email(email))) + } + + override fun checkEmailVerification(email: String, bind: Boolean) { + viewModel.handle(DiscoverySettingsAction.FinalizeBind3pid(ThreePid.Email(email), bind)) + } + + override fun checkMsisdnVerification(msisdn: String, code: String, bind: Boolean) { + viewModel.handle(DiscoverySettingsAction.SubmitMsisdnToken(msisdn, code, bind)) + } + + override fun onTapRevokeMsisdn(msisdn: String) { + viewModel.handle(DiscoverySettingsAction.RevokeThreePid(ThreePid.Msisdn(msisdn))) + } + + override fun onTapShareMsisdn(msisdn: String) { + viewModel.handle(DiscoverySettingsAction.ShareThreePid(ThreePid.Msisdn(msisdn))) + } + + override fun onTapChangeIdentityServer() = withState(viewModel) { state -> + //we should prompt if there are bound items with current is + val pidList = ArrayList().apply { + state.emailList()?.let { addAll(it) } + state.phoneNumbersList()?.let { addAll(it) } + } + + val hasBoundIds = pidList.any { it.isShared() == PidInfo.SharedState.SHARED } + + if (hasBoundIds) { + //we should prompt + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.change_identity_server) + .setMessage(getString(R.string.settings_discovery_disconnect_with_bound_pid, state.identityServer(), state.identityServer())) + .setPositiveButton(R.string._continue) { _, _ -> navigateToChangeIdentityServerFragment() } + .setNegativeButton(R.string.cancel, null) + .show() + Unit + } else { + navigateToChangeIdentityServerFragment() + } + } + + override fun onTapDisconnectIdentityServer() { + //we should prompt if there are bound items with current is + withState(viewModel) { state -> + val pidList = ArrayList().apply { + state.emailList()?.let { addAll(it) } + state.phoneNumbersList()?.let { addAll(it) } + } + + val hasBoundIds = pidList.any { it.isShared() == PidInfo.SharedState.SHARED } + + if (hasBoundIds) { + //we should prompt + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.disconnect_identity_server) + .setMessage(getString(R.string.settings_discovery_disconnect_with_bound_pid, state.identityServer(), state.identityServer())) + .setPositiveButton(R.string._continue) { _, _ -> viewModel.handle(DiscoverySettingsAction.ChangeIdentityServer(null)) } + .setNegativeButton(R.string.cancel, null) + .show() + } else { + viewModel.handle(DiscoverySettingsAction.ChangeIdentityServer(null)) + } + } + } + + override fun onTapRetryToRetrieveBindings() { + viewModel.handle(DiscoverySettingsAction.RetrieveBinding) + } + + private fun navigateToChangeIdentityServerFragment() { + parentFragmentManager.beginTransaction() + .setCustomAnimations(R.anim.anim_slide_in_bottom, R.anim.anim_slide_out_bottom, R.anim.anim_slide_in_bottom, R.anim.anim_slide_out_bottom) + .replace(R.id.vector_settings_page, SetIdentityServerFragment::class.java, null) + .addToBackStack(null) + .commit() + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/discovery/DiscoverySettingsViewModel.kt b/vector/src/main/java/im/vector/riotx/features/discovery/DiscoverySettingsViewModel.kt new file mode 100644 index 0000000000..34c5bcf66b --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/discovery/DiscoverySettingsViewModel.kt @@ -0,0 +1,503 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.riotx.features.discovery + +import com.airbnb.mvrx.Async +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.FragmentViewModelContext +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.MvRxState +import com.airbnb.mvrx.MvRxViewModelFactory +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.Uninitialized +import com.airbnb.mvrx.ViewModelContext +import com.google.i18n.phonenumbers.PhoneNumberUtil +import com.squareup.inject.assisted.Assisted +import com.squareup.inject.assisted.AssistedInject +import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.session.Session +import im.vector.matrix.android.api.session.identity.IdentityServiceListener +import im.vector.matrix.android.api.session.identity.ThreePid +import im.vector.riotx.core.extensions.exhaustive +import im.vector.riotx.core.platform.VectorViewEvents +import im.vector.riotx.core.platform.VectorViewModel +import im.vector.riotx.core.platform.VectorViewModelAction + +data class PidInfo( + val value: String, + val isShared: Async, + val _3pid: ThreePid? = null +) { + enum class SharedState { + SHARED, + NOT_SHARED, + NOT_VERIFIED_FOR_BIND, + NOT_VERIFIED_FOR_UNBIND + } +} + +data class DiscoverySettingsState( + val identityServer: Async = Uninitialized, + val emailList: Async> = Uninitialized, + val phoneNumbersList: Async> = Uninitialized, + // TODO Use ViewEvents + val termsNotSigned: Boolean = false +) : MvRxState + +sealed class DiscoverySettingsAction : VectorViewModelAction { + object RetrieveBinding : DiscoverySettingsAction() + object Refresh : DiscoverySettingsAction() + + data class ChangeIdentityServer(val url: String?) : DiscoverySettingsAction() + data class RevokeThreePid(val threePid: ThreePid) : DiscoverySettingsAction() + data class ShareThreePid(val threePid: ThreePid) : DiscoverySettingsAction() + data class FinalizeBind3pid(val threePid: ThreePid, val bind: Boolean) : DiscoverySettingsAction() + data class SubmitMsisdnToken(val msisdn: String, val code: String, val bind: Boolean) : DiscoverySettingsAction() +} + +sealed class DiscoverySettingsViewEvents : VectorViewEvents { + data class Failure(val throwable: Throwable) : DiscoverySettingsViewEvents() +} + +class DiscoverySettingsViewModel @AssistedInject constructor( + @Assisted initialState: DiscoverySettingsState, + private val session: Session) + : VectorViewModel(initialState) { + + @AssistedInject.Factory + interface Factory { + fun create(initialState: DiscoverySettingsState): DiscoverySettingsViewModel + } + + companion object : MvRxViewModelFactory { + + @JvmStatic + override fun create(viewModelContext: ViewModelContext, state: DiscoverySettingsState): DiscoverySettingsViewModel? { + val fragment: DiscoverySettingsFragment = (viewModelContext as FragmentViewModelContext).fragment() + return fragment.viewModelFactory.create(state) + } + } + + private val identityService = session.identityService() + + private val identityServerManagerListener = object : IdentityServiceListener { + override fun onIdentityServerChange() = withState { state -> + val identityServerUrl = identityService.getCurrentIdentityServer() + val currentIS = state.identityServer() + setState { + copy(identityServer = Success(identityServerUrl)) + } + if (currentIS != identityServerUrl) refreshModel() + } + } + + init { + startListenToIdentityManager() + refreshModel() + } + + override fun onCleared() { + super.onCleared() + stopListenToIdentityManager() + } + + override fun handle(action: DiscoverySettingsAction) { + when (action) { + DiscoverySettingsAction.Refresh -> refreshPendingEmailBindings() + DiscoverySettingsAction.RetrieveBinding -> retrieveBinding() + is DiscoverySettingsAction.ChangeIdentityServer -> changeIdentityServer(action) + is DiscoverySettingsAction.RevokeThreePid -> revokeThreePid(action) + is DiscoverySettingsAction.ShareThreePid -> shareThreePid(action) + is DiscoverySettingsAction.FinalizeBind3pid -> finalizeBind3pid(action) + is DiscoverySettingsAction.SubmitMsisdnToken -> submitMsisdnToken(action) + }.exhaustive + } + + private fun changeIdentityServer(action: DiscoverySettingsAction.ChangeIdentityServer) { + setState { + copy( + identityServer = Loading() + ) + } + + session.identityService().setNewIdentityServer(action.url, object : MatrixCallback { + override fun onSuccess(data: Unit) { + setState { + copy( + identityServer = Success(action.url) + ) + } + refreshModel() + } + + override fun onFailure(failure: Throwable) { + setState { + copy( + identityServer = Fail(failure) + ) + } + } + }) + } + + private fun shareThreePid(action: DiscoverySettingsAction.ShareThreePid) { + when (action.threePid) { + is ThreePid.Email -> shareEmail(action.threePid.email) + is ThreePid.Msisdn -> shareMsisdn(action.threePid.msisdn) + }.exhaustive + } + + private fun shareEmail(email: String) = withState { state -> + if (state.identityServer() == null) return@withState + changeMailState(email, Loading(), null) + + identityService.startBindSession(ThreePid.Email(email), null, + object : MatrixCallback { + override fun onSuccess(data: ThreePid) { + changeMailState(email, Success(PidInfo.SharedState.NOT_VERIFIED_FOR_BIND), data) + } + + override fun onFailure(failure: Throwable) { + _viewEvents.post(DiscoverySettingsViewEvents.Failure(failure)) + + changeMailState(email, Fail(failure)) + } + }) + } + + private fun changeMailState(address: String, state: Async, threePid: ThreePid?) { + setState { + val currentMails = emailList() ?: emptyList() + copy(emailList = Success( + currentMails.map { + if (it.value == address) { + it.copy( + _3pid = threePid, + isShared = state + ) + } else { + it + } + } + )) + } + } + + private fun changeMailState(address: String, state: Async) { + setState { + val currentMails = emailList() ?: emptyList() + copy(emailList = Success( + currentMails.map { + if (it.value == address) { + it.copy(isShared = state) + } else { + it + } + } + )) + } + } + + private fun changeMsisdnState(address: String, state: Async, threePid: ThreePid?) { + setState { + val phones = phoneNumbersList() ?: emptyList() + copy(phoneNumbersList = Success( + phones.map { + if (it.value == address) { + it.copy( + _3pid = threePid, + isShared = state + ) + } else { + it + } + } + )) + } + } + + private fun revokeThreePid(action: DiscoverySettingsAction.RevokeThreePid) { + when (action.threePid) { + is ThreePid.Email -> revokeEmail(action.threePid.email) + is ThreePid.Msisdn -> revokeMsisdn(action.threePid.msisdn) + }.exhaustive + } + + private fun revokeEmail(email: String) = withState { state -> + if (state.identityServer() == null) return@withState + if (state.emailList() == null) return@withState + changeMailState(email, Loading()) + + identityService.startUnBindSession(ThreePid.Email(email), null, object : MatrixCallback> { + override fun onSuccess(data: Pair) { + if (data.first) { + // requires mail validation + changeMailState(email, Success(PidInfo.SharedState.NOT_VERIFIED_FOR_UNBIND), data.second) + } else { + changeMailState(email, Success(PidInfo.SharedState.NOT_SHARED)) + } + } + + override fun onFailure(failure: Throwable) { + _viewEvents.post(DiscoverySettingsViewEvents.Failure(failure)) + + changeMailState(email, Fail(failure)) + } + }) + } + + private fun revokeMsisdn(msisdn: String) = withState { state -> + if (state.identityServer() == null) return@withState + if (state.emailList() == null) return@withState + changeMsisdnState(msisdn, Loading()) + + val phoneNumber = PhoneNumberUtil.getInstance() + .parse("+$msisdn", null) + val countryCode = PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(phoneNumber.countryCode) + + identityService.startUnBindSession(ThreePid.Msisdn(msisdn, countryCode), null, object : MatrixCallback> { + override fun onSuccess(data: Pair) { + if (data.first /*requires mail validation */) { + changeMsisdnState(msisdn, Success(PidInfo.SharedState.NOT_VERIFIED_FOR_UNBIND), data.second) + } else { + changeMsisdnState(msisdn, Success(PidInfo.SharedState.NOT_SHARED)) + } + } + + override fun onFailure(failure: Throwable) { + _viewEvents.post(DiscoverySettingsViewEvents.Failure(failure)) + + changeMsisdnState(msisdn, Fail(failure)) + } + }) + + } + + private fun shareMsisdn(msisdn: String) = withState { state -> + if (state.identityServer() == null) return@withState + changeMsisdnState(msisdn, Loading()) + + val phoneNumber = PhoneNumberUtil.getInstance() + .parse("+$msisdn", null) + val countryCode = PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(phoneNumber.countryCode) + + + identityService.startBindSession(ThreePid.Msisdn(msisdn, countryCode), null, object : MatrixCallback { + override fun onSuccess(data: ThreePid) { + changeMsisdnState(msisdn, Success(PidInfo.SharedState.NOT_VERIFIED_FOR_BIND), data) + } + + override fun onFailure(failure: Throwable) { + _viewEvents.post(DiscoverySettingsViewEvents.Failure(failure)) + + changeMsisdnState(msisdn, Fail(failure)) + } + }) + } + + private fun changeMsisdnState(msisdn: String, sharedState: Async) { + setState { + val currentMsisdns = phoneNumbersList()!! + copy(phoneNumbersList = Success( + currentMsisdns.map { + if (it.value == msisdn) { + it.copy(isShared = sharedState) + } else { + it + } + }) + ) + } + } + + private fun startListenToIdentityManager() { + identityService.addListener(identityServerManagerListener) + } + + private fun stopListenToIdentityManager() { + identityService.addListener(identityServerManagerListener) + } + + private fun refreshModel() = withState { state -> + if (state.identityServer().isNullOrBlank()) return@withState + + setState { + copy( + emailList = Loading(), + phoneNumbersList = Loading() + ) + } + + /* TODO + session.refreshThirdPartyIdentifiers(object : MatrixCallback { + override fun onFailure(failure: Throwable) { + _errorLiveEvent.postValue(LiveEvent(failure)) + + setState { + copy( + emailList = Fail(failure), + phoneNumbersList = Fail(failure) + ) + } + } + + override fun onSuccess(data: Unit) { + setState { + copy(termsNotSigned = false) + } + + retrieveBinding() + } + }) + */ + } + + private fun retrieveBinding() { + /* TODO + val linkedMailsInfo = session.myUser.getlinkedEmails() + val knownEmails = linkedMailsInfo.map { it.address } + // Note: it will be a list of "email" + val knownEmailMedium = linkedMailsInfo.map { it.medium } + + val linkedMsisdnsInfo = session.myUser.getlinkedPhoneNumbers() + val knownMsisdns = linkedMsisdnsInfo.map { it.address } + // Note: it will be a list of "msisdn" + val knownMsisdnMedium = linkedMsisdnsInfo.map { it.medium } + + setState { + copy( + emailList = Success(knownEmails.map { PidInfo(it, Loading()) }), + phoneNumbersList = Success(knownMsisdns.map { PidInfo(it, Loading()) }) + ) + } + + identityService.lookup(knownEmails + knownMsisdns, + knownEmailMedium + knownMsisdnMedium, + object : MatrixCallback> { + override fun onSuccess(data: List) { + setState { + copy( + emailList = Success(toPidInfoList(knownEmails, data.take(knownEmails.size))), + phoneNumbersList = Success(toPidInfoList(knownMsisdns, data.takeLast(knownMsisdns.size))) + ) + } + } + + override fun onUnexpectedError(e: Exception) { + if (e is TermsNotSignedException) { + setState { + // TODO Use ViewEvent + copy(termsNotSigned = true) + } + } + onError(e) + } + + override fun onNetworkError(e: Exception) { + onError(e) + } + + override fun onMatrixError(e: MatrixError) { + onError(Throwable(e.message)) + } + + private fun onError(e: Throwable) { + _errorLiveEvent.postValue(LiveEvent(e)) + + setState { + copy( + emailList = Success(knownEmails.map { PidInfo(it, Fail(e)) }), + phoneNumbersList = Success(knownMsisdns.map { PidInfo(it, Fail(e)) }) + ) + } + } + }) + */ + } + + private fun toPidInfoList(addressList: List, matrixIds: List): List { + return addressList.map { + val hasMatrixId = matrixIds[addressList.indexOf(it)].isNotBlank() + PidInfo( + value = it, + isShared = Success(PidInfo.SharedState.SHARED.takeIf { hasMatrixId } ?: PidInfo.SharedState.NOT_SHARED) + ) + } + } + + private fun submitMsisdnToken(action: DiscoverySettingsAction.SubmitMsisdnToken) = withState { state -> + val pid = state.phoneNumbersList()?.find { it.value == action.msisdn }?._3pid ?: return@withState + + identityService.submitValidationToken(pid, + action.code, + object : MatrixCallback { + override fun onSuccess(data: Unit) { + finalizeBind3pid(DiscoverySettingsAction.FinalizeBind3pid(ThreePid.Msisdn(action.msisdn), action.bind)) + } + + override fun onFailure(failure: Throwable) { + _viewEvents.post(DiscoverySettingsViewEvents.Failure(failure)) + changeMsisdnState(action.msisdn, Fail(failure)) + } + } + ) + } + + private fun finalizeBind3pid(action: DiscoverySettingsAction.FinalizeBind3pid) = withState { state -> + val _3pid = when (action.threePid) { + is ThreePid.Email -> { + changeMailState(action.threePid.email, Loading()) + state.emailList()?.find { it.value == action.threePid.email }?._3pid ?: return@withState + } + is ThreePid.Msisdn -> { + changeMsisdnState(action.threePid.msisdn, Loading()) + state.phoneNumbersList()?.find { it.value == action.threePid.msisdn }?._3pid ?: return@withState + } + } + + identityService.finalizeBindSessionFor3PID(_3pid, object : MatrixCallback { + override fun onSuccess(data: Unit) { + val sharedState = Success(if (action.bind) PidInfo.SharedState.SHARED else PidInfo.SharedState.NOT_SHARED) + when (action.threePid) { + is ThreePid.Email -> changeMailState(action.threePid.email, sharedState, null) + is ThreePid.Msisdn -> changeMsisdnState(action.threePid.msisdn, sharedState, null) + } + } + + override fun onFailure(failure: Throwable) { + _viewEvents.post(DiscoverySettingsViewEvents.Failure(failure)) + + // Restore previous state after an error + val sharedState = Success(if (action.bind) PidInfo.SharedState.NOT_VERIFIED_FOR_BIND else PidInfo.SharedState.NOT_VERIFIED_FOR_UNBIND) + when (action.threePid) { + is ThreePid.Email -> changeMailState(action.threePid.email, sharedState) + is ThreePid.Msisdn -> changeMsisdnState(action.threePid.msisdn, sharedState) + } + } + }) + + } + + private fun refreshPendingEmailBindings() = withState { state -> + state.emailList()?.forEach { info -> + when (info.isShared()) { + PidInfo.SharedState.NOT_VERIFIED_FOR_BIND -> finalizeBind3pid(DiscoverySettingsAction.FinalizeBind3pid(ThreePid.Email(info.value), true)) + PidInfo.SharedState.NOT_VERIFIED_FOR_UNBIND -> finalizeBind3pid(DiscoverySettingsAction.FinalizeBind3pid(ThreePid.Email(info.value), false)) + else -> Unit + } + } + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/discovery/DiscoverySharedViewModel.kt b/vector/src/main/java/im/vector/riotx/features/discovery/DiscoverySharedViewModel.kt new file mode 100644 index 0000000000..91e43187c2 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/discovery/DiscoverySharedViewModel.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.riotx.features.discovery + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import im.vector.riotx.core.utils.LiveEvent +import javax.inject.Inject + +// TODO Rework this +class DiscoverySharedViewModel @Inject constructor() : ViewModel() { + + var navigateEvent = MutableLiveData>>() + + companion object { + const val NEW_IDENTITY_SERVER_SET_REQUEST = "NEW_IDENTITY_SERVER_SET_REQUEST" + } + + fun requestChangeToIdentityServer(server: String) { + navigateEvent.postValue(LiveEvent(NEW_IDENTITY_SERVER_SET_REQUEST to server)) + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/discovery/SettingsButtonItem.kt b/vector/src/main/java/im/vector/riotx/features/discovery/SettingsButtonItem.kt new file mode 100644 index 0000000000..8deb500e82 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/discovery/SettingsButtonItem.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.riotx.features.discovery + +import android.view.View +import android.widget.Button +import androidx.annotation.StringRes +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import com.airbnb.epoxy.EpoxyModelWithHolder +import im.vector.riotx.R +import im.vector.riotx.core.epoxy.VectorEpoxyHolder +import im.vector.riotx.core.extensions.setTextOrHide +import im.vector.riotx.core.resources.ColorProvider + +@EpoxyModelClass(layout = R.layout.item_settings_button) +abstract class SettingsButtonItem : EpoxyModelWithHolder() { + + @EpoxyAttribute + lateinit var colorProvider: ColorProvider + + @EpoxyAttribute + var buttonTitle: String? = null + + @EpoxyAttribute + @StringRes + var buttonTitleId: Int? = null + + @EpoxyAttribute + var buttonStyle: SettingsTextButtonItem.ButtonStyle = SettingsTextButtonItem.ButtonStyle.POSITIVE + + @EpoxyAttribute + var buttonClickListener: View.OnClickListener? = null + + override fun bind(holder: Holder) { + super.bind(holder) + if (buttonTitleId != null) { + holder.button.setText(buttonTitleId!!) + } else { + holder.button.setTextOrHide(buttonTitle) + } + + when (buttonStyle) { + SettingsTextButtonItem.ButtonStyle.POSITIVE -> { + holder.button.setTextColor(colorProvider.getColorFromAttribute(R.attr.colorAccent)) + } + SettingsTextButtonItem.ButtonStyle.DESTRUCTIVE -> { + holder.button.setTextColor(colorProvider.getColor(R.color.vector_error_color)) + } + } + + holder.button.setOnClickListener(buttonClickListener) + } + + class Holder : VectorEpoxyHolder() { + val button by bind