From 408a0fc010aa692483036070a563d4262c21bd14 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 14 Apr 2021 12:35:33 +0200 Subject: [PATCH] Login UX flow v2 --- CHANGES.md | 1 + vector/src/main/AndroidManifest.xml | 16 + .../im/vector/app/core/di/FragmentModule.kt | 108 ++- .../im/vector/app/core/di/ScreenComponent.kt | 2 + .../app/features/login/LoginSplashFragment.kt | 8 + .../features/login2/AbstractLoginFragment2.kt | 163 ++++ .../login2/AbstractSSOLoginFragment2.kt | 101 +++ .../app/features/login2/LoginAction2.kt | 85 ++ .../app/features/login2/LoginActivity2.kt | 380 ++++++++ .../features/login2/LoginCaptchaFragment2.kt | 194 ++++ .../login2/LoginFragment2SigninPassword.kt | 179 ++++ .../login2/LoginFragment2SigninUsername.kt | 107 +++ .../login2/LoginFragment2SignupPassword.kt | 148 ++++ .../login2/LoginFragment2SignupUsername.kt | 141 +++ .../features/login2/LoginFragmentToAny2.kt | 227 +++++ .../LoginGenericTextInputFormFragment2.kt | 258 ++++++ .../login2/LoginResetPasswordFragment2.kt | 172 ++++ ...nResetPasswordMailConfirmationFragment2.kt | 75 ++ .../LoginResetPasswordSuccessFragment2.kt | 49 ++ .../login2/LoginServerSelectionFragment2.kt | 75 ++ .../login2/LoginServerUrlFormFragment2.kt | 143 +++ .../LoginSignUpSignInSelectionFragment2.kt | 72 ++ .../features/login2/LoginSsoOnlyFragment2.kt | 68 ++ .../app/features/login2/LoginViewEvents2.kt | 59 ++ .../app/features/login2/LoginViewModel2.kt | 828 ++++++++++++++++++ .../app/features/login2/LoginViewState2.kt | 69 ++ .../login2/LoginWaitForEmailFragment2.kt | 75 ++ .../app/features/login2/LoginWebFragment2.kt | 255 ++++++ .../vector/app/features/login2/SignMode2.kt | 27 + .../login2/terms/LoginTermsFragment2.kt | 119 +++ .../fragment_login_2_signin_password.xml | 113 +++ .../res/layout/fragment_login_2_signin_to.xml | 144 +++ .../fragment_login_2_signin_username.xml | 82 ++ .../fragment_login_2_signup_password.xml | 132 +++ .../fragment_login_2_signup_username.xml | 103 +++ .../fragment_login_reset_password_2.xml | 140 +++ ...ragment_login_reset_password_success_2.xml | 51 ++ .../fragment_login_server_selection_2.xml | 133 +++ .../fragment_login_server_url_form_2.xml | 71 ++ .../main/res/layout/fragment_login_splash.xml | 9 + .../res/layout/fragment_login_splash_2.xml | 224 +++++ .../res/layout/fragment_login_sso_only_2.xml | 46 + .../fragment_login_wait_for_email_2.xml | 54 ++ .../res/layout/item_login_password_form.xml | 2 + .../src/main/res/values/strings_login_v2.xml | 31 + 45 files changed, 5536 insertions(+), 3 deletions(-) create mode 100644 vector/src/main/java/im/vector/app/features/login2/AbstractLoginFragment2.kt create mode 100644 vector/src/main/java/im/vector/app/features/login2/AbstractSSOLoginFragment2.kt create mode 100644 vector/src/main/java/im/vector/app/features/login2/LoginAction2.kt create mode 100644 vector/src/main/java/im/vector/app/features/login2/LoginActivity2.kt create mode 100644 vector/src/main/java/im/vector/app/features/login2/LoginCaptchaFragment2.kt create mode 100644 vector/src/main/java/im/vector/app/features/login2/LoginFragment2SigninPassword.kt create mode 100644 vector/src/main/java/im/vector/app/features/login2/LoginFragment2SigninUsername.kt create mode 100644 vector/src/main/java/im/vector/app/features/login2/LoginFragment2SignupPassword.kt create mode 100644 vector/src/main/java/im/vector/app/features/login2/LoginFragment2SignupUsername.kt create mode 100644 vector/src/main/java/im/vector/app/features/login2/LoginFragmentToAny2.kt create mode 100644 vector/src/main/java/im/vector/app/features/login2/LoginGenericTextInputFormFragment2.kt create mode 100644 vector/src/main/java/im/vector/app/features/login2/LoginResetPasswordFragment2.kt create mode 100644 vector/src/main/java/im/vector/app/features/login2/LoginResetPasswordMailConfirmationFragment2.kt create mode 100644 vector/src/main/java/im/vector/app/features/login2/LoginResetPasswordSuccessFragment2.kt create mode 100644 vector/src/main/java/im/vector/app/features/login2/LoginServerSelectionFragment2.kt create mode 100644 vector/src/main/java/im/vector/app/features/login2/LoginServerUrlFormFragment2.kt create mode 100644 vector/src/main/java/im/vector/app/features/login2/LoginSignUpSignInSelectionFragment2.kt create mode 100644 vector/src/main/java/im/vector/app/features/login2/LoginSsoOnlyFragment2.kt create mode 100644 vector/src/main/java/im/vector/app/features/login2/LoginViewEvents2.kt create mode 100644 vector/src/main/java/im/vector/app/features/login2/LoginViewModel2.kt create mode 100644 vector/src/main/java/im/vector/app/features/login2/LoginViewState2.kt create mode 100644 vector/src/main/java/im/vector/app/features/login2/LoginWaitForEmailFragment2.kt create mode 100644 vector/src/main/java/im/vector/app/features/login2/LoginWebFragment2.kt create mode 100644 vector/src/main/java/im/vector/app/features/login2/SignMode2.kt create mode 100755 vector/src/main/java/im/vector/app/features/login2/terms/LoginTermsFragment2.kt create mode 100644 vector/src/main/res/layout/fragment_login_2_signin_password.xml create mode 100644 vector/src/main/res/layout/fragment_login_2_signin_to.xml create mode 100644 vector/src/main/res/layout/fragment_login_2_signin_username.xml create mode 100644 vector/src/main/res/layout/fragment_login_2_signup_password.xml create mode 100644 vector/src/main/res/layout/fragment_login_2_signup_username.xml create mode 100644 vector/src/main/res/layout/fragment_login_reset_password_2.xml create mode 100644 vector/src/main/res/layout/fragment_login_reset_password_success_2.xml create mode 100644 vector/src/main/res/layout/fragment_login_server_selection_2.xml create mode 100644 vector/src/main/res/layout/fragment_login_server_url_form_2.xml create mode 100644 vector/src/main/res/layout/fragment_login_splash_2.xml create mode 100644 vector/src/main/res/layout/fragment_login_sso_only_2.xml create mode 100644 vector/src/main/res/layout/fragment_login_wait_for_email_2.xml create mode 100644 vector/src/main/res/values/strings_login_v2.xml diff --git a/CHANGES.md b/CHANGES.md index 74b008bfc4..ee0d442c79 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,6 +6,7 @@ Features ✨: Improvements 🙌: - Add ability to install APK from directly from Element (#2381) + - Improve login/register flow (#2585, #3172) Bugfix 🐛: - Message states cosmetic changes (#3007) diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index 1e2bf1ab0f..b363df397e 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -119,6 +119,22 @@ android:scheme="element" /> + + + + + + + + + + + : VectorBaseFragment(), OnBackPressed { + + protected val loginViewModel: LoginViewModel2 by activityViewModel() + + private var isResetPasswordStarted = false + + // Due to async, we keep a boolean to avoid displaying twice the cancellation dialog + private var displayCancelDialog = true + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + sharedElementEnterTransition = TransitionInflater.from(context).inflateTransition(android.R.transition.move) + } + + @CallSuper + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + loginViewModel.observeViewEvents { + handleLoginViewEvents(it) + } + } + + private fun handleLoginViewEvents(loginViewEvents: LoginViewEvents2) { + when (loginViewEvents) { + is LoginViewEvents2.Failure -> showFailure(loginViewEvents.throwable) + else -> + // This is handled by the Activity + Unit + }.exhaustive + } + + override fun showFailure(throwable: Throwable) { + // Only the resumed Fragment can eventually show the error, to avoid multiple dialog display + if (!isResumed) { + return + } + + when (throwable) { + is Failure.Cancelled -> + /* Ignore this error, user has cancelled the action */ + Unit + is Failure.UnrecognizedCertificateFailure -> + showUnrecognizedCertificateFailure(throwable) + else -> + onError(throwable) + } + } + + private fun showUnrecognizedCertificateFailure(failure: Failure.UnrecognizedCertificateFailure) { + // Ask the user to accept the certificate + unrecognizedCertificateDialog.show(requireActivity(), + failure.fingerprint, + failure.url, + object : UnrecognizedCertificateDialog.Callback { + override fun onAccept() { + // User accept the certificate + loginViewModel.handle(LoginAction2.UserAcceptCertificate(failure.fingerprint)) + } + + override fun onIgnore() { + // Cannot happen in this case + } + + override fun onReject() { + // Nothing to do in this case + } + }) + } + + open fun onError(throwable: Throwable) { + super.showFailure(throwable) + } + + override fun onBackPressed(toolbarButton: Boolean): Boolean { + return when { + displayCancelDialog && loginViewModel.isRegistrationStarted -> { + // Ask for confirmation before cancelling the registration + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.login_signup_cancel_confirmation_title) + .setMessage(R.string.login_signup_cancel_confirmation_content) + .setPositiveButton(R.string.yes) { _, _ -> + displayCancelDialog = false + vectorBaseActivity.onBackPressed() + } + .setNegativeButton(R.string.no, null) + .show() + + true + } + displayCancelDialog && isResetPasswordStarted -> { + // Ask for confirmation before cancelling the reset password + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.login_reset_password_cancel_confirmation_title) + .setMessage(R.string.login_reset_password_cancel_confirmation_content) + .setPositiveButton(R.string.yes) { _, _ -> + displayCancelDialog = false + vectorBaseActivity.onBackPressed() + } + .setNegativeButton(R.string.no, null) + .show() + + true + } + else -> { + resetViewModel() + // Do not consume the Back event + false + } + } + } + + final override fun invalidate() = withState(loginViewModel) { state -> + // True when email is sent with success to the homeserver + isResetPasswordStarted = state.resetPasswordEmail.isNullOrBlank().not() + + updateWithState(state) + } + + open fun updateWithState(state: LoginViewState2) { + // No op by default + } + + // Reset any modification on the loginViewModel by the current fragment + abstract fun resetViewModel() +} diff --git a/vector/src/main/java/im/vector/app/features/login2/AbstractSSOLoginFragment2.kt b/vector/src/main/java/im/vector/app/features/login2/AbstractSSOLoginFragment2.kt new file mode 100644 index 0000000000..b12d9638dc --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/AbstractSSOLoginFragment2.kt @@ -0,0 +1,101 @@ +/* + * 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.app.features.login2 + +import android.content.ComponentName +import android.net.Uri +import androidx.browser.customtabs.CustomTabsClient +import androidx.browser.customtabs.CustomTabsServiceConnection +import androidx.browser.customtabs.CustomTabsSession +import androidx.viewbinding.ViewBinding +import com.airbnb.mvrx.withState +import im.vector.app.core.utils.openUrlInChromeCustomTab +import im.vector.app.features.login.hasSso +import im.vector.app.features.login.ssoIdentityProviders + +abstract class AbstractSSOLoginFragment2 : AbstractLoginFragment2() { + + // For sso + private var customTabsServiceConnection: CustomTabsServiceConnection? = null + private var customTabsClient: CustomTabsClient? = null + private var customTabsSession: CustomTabsSession? = null + + override fun onStart() { + super.onStart() + val hasSSO = withState(loginViewModel) { it.loginMode.hasSso() } + if (hasSSO) { + val packageName = CustomTabsClient.getPackageName(requireContext(), null) + + // packageName can be null if there are 0 or several CustomTabs compatible browsers installed on the device + if (packageName != null) { + customTabsServiceConnection = object : CustomTabsServiceConnection() { + override fun onCustomTabsServiceConnected(name: ComponentName, client: CustomTabsClient) { + customTabsClient = client + .also { it.warmup(0L) } + prefetchIfNeeded() + } + + override fun onServiceDisconnected(name: ComponentName?) { + } + } + .also { + CustomTabsClient.bindCustomTabsService( + requireContext(), + // Despite the API, packageName cannot be null + packageName, + it + ) + } + } + } + } + + override fun onStop() { + super.onStop() + val hasSSO = withState(loginViewModel) { it.loginMode.hasSso() } + if (hasSSO) { + customTabsServiceConnection?.let { requireContext().unbindService(it) } + customTabsServiceConnection = null + } + } + + private fun prefetchUrl(url: String) { + if (customTabsSession == null) { + customTabsSession = customTabsClient?.newSession(null) + } + + customTabsSession?.mayLaunchUrl(Uri.parse(url), null, null) + } + + protected fun openInCustomTab(ssoUrl: String) { + openUrlInChromeCustomTab(requireContext(), customTabsSession, ssoUrl) + } + + private fun prefetchIfNeeded() { + withState(loginViewModel) { state -> + if (state.loginMode.hasSso() && state.loginMode.ssoIdentityProviders().isNullOrEmpty()) { + // in this case we can prefetch (not other cases for privacy concerns) + loginViewModel.getSsoUrl( + redirectUrl = LoginActivity2.VECTOR_REDIRECT_URL, + deviceId = state.deviceId, + providerId = null + ) + ?.let { prefetchUrl(it) } + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginAction2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginAction2.kt new file mode 100644 index 0000000000..8f3e88abbb --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginAction2.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2019 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.login2 + +import im.vector.app.core.platform.VectorViewModelAction +import im.vector.app.features.login.LoginConfig +import org.matrix.android.sdk.api.auth.data.Credentials +import org.matrix.android.sdk.api.auth.data.SsoIdentityProvider +import org.matrix.android.sdk.api.auth.registration.RegisterThreePid +import org.matrix.android.sdk.internal.network.ssl.Fingerprint + +sealed class LoginAction2 : VectorViewModelAction { + // First action + data class UpdateSignMode(val signMode: SignMode2) : LoginAction2() + + // Signin, but user wants to choose a server + object ChooseAServerForSignin : LoginAction2() + + object EnterServerUrl : LoginAction2() + object ChooseDefaultHomeServer : LoginAction2() + data class UpdateHomeServer(val homeServerUrl: String) : LoginAction2() + data class LoginWithToken(val loginToken: String) : LoginAction2() + data class WebLoginSuccess(val credentials: Credentials) : LoginAction2() + data class InitWith(val loginConfig: LoginConfig?) : LoginAction2() + data class ResetPassword(val email: String, val newPassword: String) : LoginAction2() + object ResetPasswordMailConfirmed : LoginAction2() + + // Username to Login or Register, depending on the signMode + data class SetUserName(val username: String) : LoginAction2() + // Password to Login or Register, depending on the signMode + data class SetUserPassword(val password: String) : LoginAction2() + + // When user has selected a homeserver + data class LoginWith(val login: String, val password: String) : LoginAction2() + + // Register actions + open class RegisterAction : LoginAction2() + + data class AddThreePid(val threePid: RegisterThreePid) : RegisterAction() + object SendAgainThreePid : RegisterAction() + + // TODO Confirm Email (from link in the email, open in the phone, intercepted by RiotX) + data class ValidateThreePid(val code: String) : RegisterAction() + + data class CheckIfEmailHasBeenValidated(val delayMillis: Long) : RegisterAction() + object StopEmailValidationCheck : RegisterAction() + + data class CaptchaDone(val captchaResponse: String) : RegisterAction() + object AcceptTerms : RegisterAction() + object RegisterDummy : RegisterAction() + + // Reset actions + open class ResetAction : LoginAction2() + + object ResetHomeServerUrl : ResetAction() + object ResetSignMode : ResetAction() + object ResetLogin : ResetAction() + object ResetResetPassword : ResetAction() + + // Homeserver history + object ClearHomeServerHistory : LoginAction2() + + // For the soft logout case + data class SetupSsoForSessionRecovery(val homeServerUrl: String, + val deviceId: String, + val ssoIdentityProviders: List?) : LoginAction2() + + data class PostViewEvent(val viewEvent: LoginViewEvents2) : LoginAction2() + + data class UserAcceptCertificate(val fingerprint: Fingerprint) : LoginAction2() +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginActivity2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginActivity2.kt new file mode 100644 index 0000000000..149abb69da --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginActivity2.kt @@ -0,0 +1,380 @@ +/* + * Copyright 2019 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.login2 + +import android.content.Context +import android.content.Intent +import android.view.View +import android.view.ViewGroup +import androidx.annotation.CallSuper +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.Toolbar +import androidx.core.view.ViewCompat +import androidx.core.view.children +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentTransaction +import com.airbnb.mvrx.viewModel +import im.vector.app.R +import im.vector.app.core.di.ScreenComponent +import im.vector.app.core.extensions.POP_BACK_STACK_EXCLUSIVE +import im.vector.app.core.extensions.addFragment +import im.vector.app.core.extensions.addFragmentToBackstack +import im.vector.app.core.extensions.exhaustive +import im.vector.app.core.platform.ToolbarConfigurable +import im.vector.app.core.platform.VectorBaseActivity +import im.vector.app.databinding.ActivityLoginBinding +import im.vector.app.features.home.HomeActivity +import im.vector.app.features.login.LoginCaptchaFragmentArgument +import im.vector.app.features.login.LoginConfig +import im.vector.app.features.login.LoginWaitForEmailFragmentArgument +import im.vector.app.features.login.isSupported +import im.vector.app.features.login.terms.LoginTermsFragmentArgument +import im.vector.app.features.login.terms.toLocalizedLoginTerms +import im.vector.app.features.login2.terms.LoginTermsFragment2 +import im.vector.app.features.pin.UnlockedActivity + +import org.matrix.android.sdk.api.auth.registration.FlowResult +import org.matrix.android.sdk.api.auth.registration.Stage +import org.matrix.android.sdk.api.extensions.tryOrNull +import javax.inject.Inject + +/** + * The LoginActivity manages the fragment navigation and also display the loading View + */ +open class LoginActivity2 : VectorBaseActivity(), ToolbarConfigurable, UnlockedActivity { + + private val loginViewModel: LoginViewModel2 by viewModel() + + @Inject lateinit var loginViewModelFactory: LoginViewModel2.Factory + + @CallSuper + override fun injectWith(injector: ScreenComponent) { + injector.inject(this) + } + + private val enterAnim = R.anim.enter_fade_in + private val exitAnim = R.anim.exit_fade_out + + private val popEnterAnim = R.anim.no_anim + private val popExitAnim = R.anim.exit_fade_out + + private val topFragment: Fragment? + get() = supportFragmentManager.findFragmentById(R.id.loginFragmentContainer) + + private val commonOption: (FragmentTransaction) -> Unit = { ft -> + // Find the loginLogo on the current Fragment, this should not return null + (topFragment?.view as? ViewGroup) + // Find findViewById does not work, I do not know why + // findViewById(R.id.loginLogo) + ?.children + ?.firstOrNull { it.id == R.id.loginLogo } + ?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") } + ft.setCustomAnimations(enterAnim, exitAnim, popEnterAnim, popExitAnim) + } + + final override fun getBinding() = ActivityLoginBinding.inflate(layoutInflater) + + override fun getCoordinatorLayout() = views.coordinatorLayout + + override fun initUiAndData() { + if (isFirstCreation()) { + addFirstFragment() + } + + loginViewModel + .subscribe(this) { + updateWithState(it) + } + + loginViewModel.observeViewEvents { handleLoginViewEvents(it) } + + // Get config extra + val loginConfig = intent.getParcelableExtra(EXTRA_CONFIG) + if (isFirstCreation()) { + // TODO Check this + loginViewModel.handle(LoginAction2.InitWith(loginConfig)) + } + } + + protected open fun addFirstFragment() { + addFragment(R.id.loginFragmentContainer, LoginSignUpSignInSelectionFragment2::class.java) + } + + private fun handleLoginViewEvents(event: LoginViewEvents2) { + when (event) { + is LoginViewEvents2.RegistrationFlowResult -> { + // Check that all flows are supported by the application + if (event.flowResult.missingStages.any { !it.isSupported() }) { + // Display a popup to propose use web fallback + onRegistrationStageNotSupported() + } else { + if (event.isRegistrationStarted) { + // Go on with registration flow + handleRegistrationNavigation(event.flowResult) + } else { + /* + // First ask for login and password + // I add a tag to indicate that this fragment is a registration stage. + // This way it will be automatically popped in when starting the next registration stage + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginFragment2::class.java, + tag = FRAGMENT_REGISTRATION_STAGE_TAG, + option = commonOption + ) + + */ + } + } + } + is LoginViewEvents2.OutdatedHomeserver -> { + AlertDialog.Builder(this) + .setTitle(R.string.login_error_outdated_homeserver_title) + .setMessage(R.string.login_error_outdated_homeserver_warning_content) + .setPositiveButton(R.string.ok, null) + .show() + Unit + } + is LoginViewEvents2.OpenServerSelection -> + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginServerSelectionFragment2::class.java, + option = { ft -> + findViewById(R.id.loginSplashLogo)?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") } + // Disable transition of text + // findViewById(R.id.loginSplashTitle)?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") } + // No transition here now actually + // findViewById(R.id.loginSplashSubmit)?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") } + // TODO Disabled because it provokes a flickering + // ft.setCustomAnimations(enterAnim, exitAnim, popEnterAnim, popExitAnim) + }) + is LoginViewEvents2.OpenHomeServerUrlFormScreen -> { + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginServerUrlFormFragment2::class.java, + option = commonOption) + } + is LoginViewEvents2.OpenSignInEnterIdentifierScreen -> { + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginFragment2SigninUsername::class.java, + option = { ft -> + findViewById(R.id.loginSplashLogo)?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") } + // Disable transition of text + // findViewById(R.id.loginSplashTitle)?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") } + // No transition here now actually + // findViewById(R.id.loginSplashSubmit)?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") } + // TODO Disabled because it provokes a flickering + // ft.setCustomAnimations(enterAnim, exitAnim, popEnterAnim, popExitAnim) + }) + } + is LoginViewEvents2.OpenSsoOnlyScreen -> { + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginSsoOnlyFragment2::class.java, + option = commonOption) + } + is LoginViewEvents2.OnWebLoginError -> onWebLoginError(event) + is LoginViewEvents2.OpenResetPasswordScreen -> + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginResetPasswordFragment2::class.java, + option = commonOption) + is LoginViewEvents2.OnResetPasswordSendThreePidDone -> { + supportFragmentManager.popBackStack(FRAGMENT_LOGIN_TAG, POP_BACK_STACK_EXCLUSIVE) + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginResetPasswordMailConfirmationFragment2::class.java, + option = commonOption) + } + is LoginViewEvents2.OnResetPasswordMailConfirmationSuccess -> { + supportFragmentManager.popBackStack(FRAGMENT_LOGIN_TAG, POP_BACK_STACK_EXCLUSIVE) + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginResetPasswordSuccessFragment2::class.java, + option = commonOption) + } + is LoginViewEvents2.OnResetPasswordMailConfirmationSuccessDone -> { + // Go back to the login fragment + supportFragmentManager.popBackStack(FRAGMENT_LOGIN_TAG, POP_BACK_STACK_EXCLUSIVE) + } + is LoginViewEvents2.OnSendEmailSuccess -> + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginWaitForEmailFragment2::class.java, + LoginWaitForEmailFragmentArgument(event.email), + tag = FRAGMENT_REGISTRATION_STAGE_TAG, + option = commonOption) + is LoginViewEvents2.OpenPasswordScreen -> { + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginFragment2SigninPassword::class.java, + option = commonOption) + } + is LoginViewEvents2.OpenSignupPasswordScreen -> { + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginFragment2SignupPassword::class.java, + tag = FRAGMENT_REGISTRATION_STAGE_TAG, + option = commonOption) + } + is LoginViewEvents2.OpenSignUpChooseUsernameScreen -> { + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginFragment2SignupUsername::class.java, + option = commonOption) + } + is LoginViewEvents2.OpenSignInWithAnythingScreen -> { + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginFragmentToAny2::class.java, + option = commonOption) + } + is LoginViewEvents2.OnSendMsisdnSuccess -> + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginGenericTextInputFormFragment2::class.java, + LoginGenericTextInputFormFragmentArgument(TextInputFormFragmentMode.ConfirmMsisdn, true, event.msisdn), + tag = FRAGMENT_REGISTRATION_STAGE_TAG, + option = commonOption) + is LoginViewEvents2.Failure -> + // This is handled by the Fragments + Unit + is LoginViewEvents2.OnLoginModeNotSupported -> + onLoginModeNotSupported(event.supportedTypes) + is LoginViewEvents2.OnSessionCreated -> handleOnSessionCreated(event) + }.exhaustive + } + + private fun handleOnSessionCreated(event: LoginViewEvents2.OnSessionCreated) { + // TODO Propose to set avatar and display name + val intent = HomeActivity.newIntent( + this, + accountCreation = event.newAccount + ) + startActivity(intent) + finish() + } + + private fun updateWithState(LoginViewState2: LoginViewState2) { + // Loading + views.loginLoading.isVisible = LoginViewState2.isLoading + } + + private fun onWebLoginError(onWebLoginError: LoginViewEvents2.OnWebLoginError) { + // Pop the backstack + supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) + + // And inform the user + AlertDialog.Builder(this) + .setTitle(R.string.dialog_title_error) + .setMessage(getString(R.string.login_sso_error_message, onWebLoginError.description, onWebLoginError.errorCode)) + .setPositiveButton(R.string.ok, null) + .show() + } + + /** + * Handle the SSO redirection here + */ + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + + intent?.data + ?.let { tryOrNull { it.getQueryParameter("loginToken") } } + ?.let { loginViewModel.handle(LoginAction2.LoginWithToken(it)) } + } + + private fun onRegistrationStageNotSupported() { + AlertDialog.Builder(this) + .setTitle(R.string.app_name) + .setMessage(getString(R.string.login_registration_not_supported)) + .setPositiveButton(R.string.yes) { _, _ -> + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginWebFragment2::class.java, + option = commonOption) + } + .setNegativeButton(R.string.no, null) + .show() + } + + private fun onLoginModeNotSupported(supportedTypes: List) { + AlertDialog.Builder(this) + .setTitle(R.string.app_name) + .setMessage(getString(R.string.login_mode_not_supported, supportedTypes.joinToString { "'$it'" })) + .setPositiveButton(R.string.yes) { _, _ -> + addFragmentToBackstack(R.id.loginFragmentContainer, + LoginWebFragment2::class.java, + option = commonOption) + } + .setNegativeButton(R.string.no, null) + .show() + } + + private fun handleRegistrationNavigation(flowResult: FlowResult) { + // Complete all mandatory stages first + val mandatoryStage = flowResult.missingStages.firstOrNull { it.mandatory } + + if (mandatoryStage != null) { + doStage(mandatoryStage) + } else { + // Consider optional stages + val optionalStage = flowResult.missingStages.firstOrNull { !it.mandatory && it !is Stage.Dummy } + if (optionalStage == null) { + // Should not happen... + } else { + doStage(optionalStage) + } + } + } + + private fun doStage(stage: Stage) { + // Ensure there is no fragment for registration stage in the backstack + supportFragmentManager.popBackStack(FRAGMENT_REGISTRATION_STAGE_TAG, FragmentManager.POP_BACK_STACK_INCLUSIVE) + + when (stage) { + is Stage.ReCaptcha -> addFragmentToBackstack(R.id.loginFragmentContainer, + LoginCaptchaFragment2::class.java, + LoginCaptchaFragmentArgument(stage.publicKey), + tag = FRAGMENT_REGISTRATION_STAGE_TAG, + option = commonOption) + is Stage.Email -> addFragmentToBackstack(R.id.loginFragmentContainer, + LoginGenericTextInputFormFragment2::class.java, + LoginGenericTextInputFormFragmentArgument(TextInputFormFragmentMode.SetEmail, stage.mandatory), + tag = FRAGMENT_REGISTRATION_STAGE_TAG, + option = commonOption) + is Stage.Msisdn -> addFragmentToBackstack(R.id.loginFragmentContainer, + LoginGenericTextInputFormFragment2::class.java, + LoginGenericTextInputFormFragmentArgument(TextInputFormFragmentMode.SetMsisdn, stage.mandatory), + tag = FRAGMENT_REGISTRATION_STAGE_TAG, + option = commonOption) + is Stage.Terms -> addFragmentToBackstack(R.id.loginFragmentContainer, + LoginTermsFragment2::class.java, + LoginTermsFragmentArgument(stage.policies.toLocalizedLoginTerms(getString(R.string.resources_language))), + tag = FRAGMENT_REGISTRATION_STAGE_TAG, + option = commonOption) + else -> Unit // Should not happen + } + } + + override fun configure(toolbar: Toolbar) { + configureToolbar(toolbar) + } + + companion object { + private const val FRAGMENT_REGISTRATION_STAGE_TAG = "FRAGMENT_REGISTRATION_STAGE_TAG" + private const val FRAGMENT_LOGIN_TAG = "FRAGMENT_LOGIN_TAG" + + private const val EXTRA_CONFIG = "EXTRA_CONFIG" + + // Note that the domain can be displayed to the user for confirmation that he trusts it. So use a human readable string + const val VECTOR_REDIRECT_URL = "element://connect" + + fun newIntent(context: Context, loginConfig: LoginConfig?): Intent { + return Intent(context, LoginActivity2::class.java).apply { + putExtra(EXTRA_CONFIG, loginConfig) + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginCaptchaFragment2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginCaptchaFragment2.kt new file mode 100644 index 0000000000..9c3ef6b94d --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginCaptchaFragment2.kt @@ -0,0 +1,194 @@ +/* + * Copyright 2019 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.login2 + +import android.annotation.SuppressLint +import android.content.DialogInterface +import android.graphics.Bitmap +import android.net.http.SslError +import android.os.Build +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.ViewGroup +import android.webkit.SslErrorHandler +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isVisible +import com.airbnb.mvrx.args +import im.vector.app.R +import im.vector.app.core.utils.AssetReader +import im.vector.app.databinding.FragmentLoginCaptchaBinding +import im.vector.app.features.login.JavascriptResponse +import im.vector.app.features.login.LoginCaptchaFragmentArgument +import org.matrix.android.sdk.internal.di.MoshiProvider +import timber.log.Timber +import java.net.URLDecoder +import java.util.Formatter +import javax.inject.Inject + +/** + * In this screen, the user is asked to confirm he is not a robot + */ +class LoginCaptchaFragment2 @Inject constructor( + private val assetReader: AssetReader +) : AbstractLoginFragment2() { + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginCaptchaBinding { + return FragmentLoginCaptchaBinding.inflate(inflater, container, false) + } + + private val params: LoginCaptchaFragmentArgument by args() + + private var isWebViewLoaded = false + + @SuppressLint("SetJavaScriptEnabled") + private fun setupWebView(state: LoginViewState2) { + views.loginCaptchaWevView.settings.javaScriptEnabled = true + + val reCaptchaPage = assetReader.readAssetFile("reCaptchaPage.html") ?: error("missing asset reCaptchaPage.html") + + val html = Formatter().format(reCaptchaPage, params.siteKey).toString() + val mime = "text/html" + val encoding = "utf-8" + + val homeServerUrl = state.homeServerUrl ?: error("missing url of homeserver") + views.loginCaptchaWevView.loadDataWithBaseURL(homeServerUrl, html, mime, encoding, null) + views.loginCaptchaWevView.requestLayout() + + views.loginCaptchaWevView.webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + + if (!isAdded) { + return + } + + // Show loader + views.loginCaptchaProgress.isVisible = true + } + + override fun onPageFinished(view: WebView, url: String) { + super.onPageFinished(view, url) + + if (!isAdded) { + return + } + + // Hide loader + views.loginCaptchaProgress.isVisible = false + } + + override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, error: SslError) { + Timber.d("## onReceivedSslError() : ${error.certificate}") + + if (!isAdded) { + return + } + + AlertDialog.Builder(requireActivity()) + .setMessage(R.string.ssl_could_not_verify) + .setPositiveButton(R.string.ssl_trust) { _, _ -> + Timber.d("## onReceivedSslError() : the user trusted") + handler.proceed() + } + .setNegativeButton(R.string.ssl_do_not_trust) { _, _ -> + Timber.d("## onReceivedSslError() : the user did not trust") + handler.cancel() + } + .setOnKeyListener(DialogInterface.OnKeyListener { dialog, keyCode, event -> + if (event.action == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) { + handler.cancel() + Timber.d("## onReceivedSslError() : the user dismisses the trust dialog.") + dialog.dismiss() + return@OnKeyListener true + } + false + }) + .setCancelable(false) + .show() + } + + // common error message + private fun onError(errorMessage: String) { + Timber.e("## onError() : $errorMessage") + + // TODO + // Toast.makeText(this@AccountCreationCaptchaActivity, errorMessage, Toast.LENGTH_LONG).show() + + // on error case, close this activity + // runOnUiThread(Runnable { finish() }) + } + + @SuppressLint("NewApi") + override fun onReceivedHttpError(view: WebView, request: WebResourceRequest, errorResponse: WebResourceResponse) { + super.onReceivedHttpError(view, request, errorResponse) + + if (request.url.toString().endsWith("favicon.ico")) { + // Ignore this error + return + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + onError(errorResponse.reasonPhrase) + } else { + onError(errorResponse.toString()) + } + } + + override fun onReceivedError(view: WebView, errorCode: Int, description: String, failingUrl: String) { + @Suppress("DEPRECATION") + super.onReceivedError(view, errorCode, description, failingUrl) + onError(description) + } + + override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean { + if (url?.startsWith("js:") == true) { + var json = url.substring(3) + var javascriptResponse: JavascriptResponse? = null + + try { + // URL decode + json = URLDecoder.decode(json, "UTF-8") + javascriptResponse = MoshiProvider.providesMoshi().adapter(JavascriptResponse::class.java).fromJson(json) + } catch (e: Exception) { + Timber.e(e, "## shouldOverrideUrlLoading(): failed") + } + + val response = javascriptResponse?.response + if (javascriptResponse?.action == "verifyCallback" && response != null) { + loginViewModel.handle(LoginAction2.CaptchaDone(response)) + } + } + return true + } + } + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction2.ResetLogin) + } + + override fun updateWithState(state: LoginViewState2) { + if (!isWebViewLoaded) { + setupWebView(state) + isWebViewLoaded = true + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginFragment2SigninPassword.kt b/vector/src/main/java/im/vector/app/features/login2/LoginFragment2SigninPassword.kt new file mode 100644 index 0000000000..9a644613e1 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginFragment2SigninPassword.kt @@ -0,0 +1,179 @@ +/* + * Copyright 2019 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.login2 + +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import androidx.autofill.HintConstants +import androidx.core.view.isVisible +import com.bumptech.glide.Glide +import com.bumptech.glide.request.RequestOptions +import com.jakewharton.rxbinding3.widget.textChanges +import im.vector.app.R +import im.vector.app.core.extensions.hideKeyboard +import im.vector.app.core.extensions.setTextOrHide +import im.vector.app.core.extensions.showPassword +import im.vector.app.databinding.FragmentLogin2SigninPasswordBinding +import io.reactivex.rxkotlin.subscribeBy +import org.matrix.android.sdk.api.failure.isInvalidPassword +import javax.inject.Inject + +/** + * In this screen: + * In signin mode: + * - the user is asked for password to sign in to a homeserver. + * - He also can reset his password + */ +class LoginFragment2SigninPassword @Inject constructor() : AbstractSSOLoginFragment2() { + + private var passwordShown = false + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLogin2SigninPasswordBinding { + return FragmentLogin2SigninPasswordBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupSubmitButton() + setupForgottenPasswordButton() + setupPasswordReveal() + setupAutoFill() + + views.passwordField.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + submit() + return@setOnEditorActionListener true + } + return@setOnEditorActionListener false + } + } + + private fun setupForgottenPasswordButton() { + views.forgetPasswordButton.setOnClickListener { forgetPasswordClicked() } + } + + private fun setupAutoFill() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + views.passwordField.setAutofillHints(HintConstants.AUTOFILL_HINT_PASSWORD) + } + } + + private fun submit() { + cleanupUi() + + val password = views.passwordField.text.toString() + + // This can be called by the IME action, so deal with empty cases + var error = 0 + if (password.isEmpty()) { + views.passwordFieldTil.error = getString(R.string.error_empty_field_your_password) + error++ + } + + if (error == 0) { + loginViewModel.handle(LoginAction2.SetUserPassword(password)) + } + } + + private fun cleanupUi() { + views.loginSubmit.hideKeyboard() + views.passwordFieldTil.error = null + } + + private fun setupUi(state: LoginViewState2) { + // Name and avatar + views.loginWelcomeBack.text = getString( + R.string.login_welcome_back, + state.loginProfileInfo?.displayName?.takeIf { it.isNotBlank() } ?: state.userIdentifier() + ) + + if (state.loginProfileInfo != null) { + views.loginUserIcon.isVisible = true + Glide.with(requireContext()) + .load(state.loginProfileInfo.fullAvatarUrl) + .apply(RequestOptions.circleCropTransform()) + .into(views.loginUserIcon) + } else { + views.loginUserIcon.isVisible = false + } + } + + private fun setupSubmitButton() { + views.loginSubmit.setOnClickListener { submit() } + views.passwordField + .textChanges() + .map { it.isNotEmpty() } + .subscribeBy { + views.passwordFieldTil.error = null + views.loginSubmit.isEnabled = it + } + .disposeOnDestroyView() + } + + private fun forgetPasswordClicked() { + loginViewModel.handle(LoginAction2.PostViewEvent(LoginViewEvents2.OpenResetPasswordScreen)) + } + + private fun setupPasswordReveal() { + passwordShown = false + + views.passwordReveal.setOnClickListener { + passwordShown = !passwordShown + + renderPasswordField() + } + + renderPasswordField() + } + + private fun renderPasswordField() { + views.passwordField.showPassword(passwordShown) + views.passwordReveal.render(passwordShown) + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction2.ResetLogin) + } + + override fun onError(throwable: Throwable) { + if (throwable.isInvalidPassword() && spaceInPassword()) { + views.passwordFieldTil.error = getString(R.string.auth_invalid_login_param_space_in_password) + } else { + views.passwordFieldTil.error = errorFormatter.toHumanReadable(throwable) + } + } + + override fun updateWithState(state: LoginViewState2) { + setupUi(state) + + if (state.isLoading) { + // Ensure password is hidden + passwordShown = false + renderPasswordField() + } + } + + /** + * Detect if password ends or starts with spaces + */ + private fun spaceInPassword() = views.passwordField.text.toString().let { it.trim() != it } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginFragment2SigninUsername.kt b/vector/src/main/java/im/vector/app/features/login2/LoginFragment2SigninUsername.kt new file mode 100644 index 0000000000..ee83a0409a --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginFragment2SigninUsername.kt @@ -0,0 +1,107 @@ +/* + * Copyright 2019 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.login2 + +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.autofill.HintConstants +import com.jakewharton.rxbinding3.widget.textChanges +import im.vector.app.R +import im.vector.app.core.extensions.hideKeyboard +import im.vector.app.databinding.FragmentLogin2SigninUsernameBinding +import io.reactivex.rxkotlin.subscribeBy +import org.matrix.android.sdk.api.failure.Failure +import org.matrix.android.sdk.api.failure.MatrixError +import javax.inject.Inject + +/** + * In this screen: + * - the user is asked for its matrix ID, and have the possibility to open the screen to select a server + */ +class LoginFragment2SigninUsername @Inject constructor() : AbstractLoginFragment2() { + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLogin2SigninUsernameBinding { + return FragmentLogin2SigninUsernameBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupSubmitButton() + setupAutoFill() + views.loginChooseAServer.setOnClickListener { + loginViewModel.handle(LoginAction2.ChooseAServerForSignin) + } + } + + private fun setupAutoFill() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + views.loginField.setAutofillHints(HintConstants.AUTOFILL_HINT_USERNAME) + } + } + + private fun submit() { + cleanupUi() + + val login = views.loginField.text.toString() + + // This can be called by the IME action, so deal with empty cases + var error = 0 + if (login.isEmpty()) { + views.loginFieldTil.error = getString(R.string.error_empty_field_enter_user_name) + error++ + } + + if (error == 0) { + loginViewModel.handle(LoginAction2.SetUserName(login)) + } + } + + private fun cleanupUi() { + views.loginSubmit.hideKeyboard() + views.loginFieldTil.error = null + } + + private fun setupSubmitButton() { + views.loginSubmit.setOnClickListener { submit() } + views.loginField.textChanges() + .map { it.trim().isNotEmpty() } + .subscribeBy { + views.loginFieldTil.error = null + views.loginSubmit.isEnabled = it + } + .disposeOnDestroyView() + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction2.ResetLogin) + } + + override fun onError(throwable: Throwable) { + if (throwable is Failure.ServerError + && throwable.error.code == MatrixError.M_FORBIDDEN + && throwable.error.message.isEmpty()) { + // Login with email, but email unknown + views.loginFieldTil.error = getString(R.string.login_login_with_email_error) + } else { + views.loginFieldTil.error = errorFormatter.toHumanReadable(throwable) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginFragment2SignupPassword.kt b/vector/src/main/java/im/vector/app/features/login2/LoginFragment2SignupPassword.kt new file mode 100644 index 0000000000..917e97306c --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginFragment2SignupPassword.kt @@ -0,0 +1,148 @@ +/* + * Copyright 2019 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.login2 + +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import androidx.autofill.HintConstants +import com.jakewharton.rxbinding3.widget.textChanges +import im.vector.app.R +import im.vector.app.core.extensions.hideKeyboard +import im.vector.app.core.extensions.showPassword +import im.vector.app.databinding.FragmentLogin2SignupPasswordBinding +import io.reactivex.rxkotlin.Observables +import io.reactivex.rxkotlin.subscribeBy +import javax.inject.Inject + +/** + * In this screen: + * - the user is asked for password to sign up to a homeserver. + */ +class LoginFragment2SignupPassword @Inject constructor() : AbstractSSOLoginFragment2() { + + private var passwordsShown = false + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLogin2SignupPasswordBinding { + return FragmentLogin2SignupPasswordBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupSubmitButton() + setupAutoFill() + setupPasswordReveal() + + views.passwordFieldRepeat.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + submit() + return@setOnEditorActionListener true + } + return@setOnEditorActionListener false + } + } + + private fun setupAutoFill() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + views.passwordField.setAutofillHints(HintConstants.AUTOFILL_HINT_NEW_PASSWORD) + views.passwordFieldRepeat.setAutofillHints(HintConstants.AUTOFILL_HINT_NEW_PASSWORD) + } + } + + private fun submit() { + cleanupUi() + + val password = views.passwordField.text.toString() + + // This can be called by the IME action, so deal with empty cases + var error = 0 + if (password.isEmpty()) { + views.passwordFieldTil.error = getString(R.string.error_empty_field_choose_password) + error++ + } + + val passwordRepeat = views.passwordFieldRepeat.text.toString() + + if (password != passwordRepeat) { + views.passwordFieldTilRepeat.error = getString(R.string.auth_password_dont_match) + error++ + } + + if (error == 0) { + loginViewModel.handle(LoginAction2.SetUserPassword(password)) + } + } + + private fun cleanupUi() { + views.loginSubmit.hideKeyboard() + views.passwordFieldTil.error = null + } + + private fun setupSubmitButton() { + views.loginSubmit.setOnClickListener { submit() } + Observables.combineLatest( + views.passwordField.textChanges(), + views.passwordFieldRepeat.textChanges() + ) + .subscribeBy { (password, passwordRepeat) -> + views.passwordFieldTil.error = null + views.passwordFieldTilRepeat.error = null + views.loginSubmit.isEnabled = password.isNotEmpty() && passwordRepeat.isNotEmpty() + } + .disposeOnDestroyView() + } + + private fun setupPasswordReveal() { + passwordsShown = false + + views.passwordReveal.setOnClickListener { + passwordsShown = !passwordsShown + + renderPasswordField() + } + + renderPasswordField() + } + + private fun renderPasswordField() { + views.passwordReveal.render(passwordsShown) + views.passwordField.showPassword(passwordsShown) + views.passwordFieldRepeat.showPassword(passwordsShown) + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction2.ResetLogin) + } + + override fun onError(throwable: Throwable) { + views.passwordFieldTil.error = errorFormatter.toHumanReadable(throwable) + } + + override fun updateWithState(state: LoginViewState2) { + views.loginMatrixIdentifier.text = state.userIdentifier() + + if (state.isLoading) { + // Ensure passwords are hidden + passwordsShown = false + renderPasswordField() + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginFragment2SignupUsername.kt b/vector/src/main/java/im/vector/app/features/login2/LoginFragment2SignupUsername.kt new file mode 100644 index 0000000000..3667f2f1b9 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginFragment2SignupUsername.kt @@ -0,0 +1,141 @@ +/* + * Copyright 2019 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.login2 + +import android.annotation.SuppressLint +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.autofill.HintConstants +import androidx.core.text.isDigitsOnly +import androidx.core.view.isVisible +import com.jakewharton.rxbinding3.widget.textChanges +import im.vector.app.R +import im.vector.app.core.extensions.hideKeyboard +import im.vector.app.core.extensions.toReducedUrl +import im.vector.app.databinding.FragmentLogin2SignupUsernameBinding +import im.vector.app.features.login.LoginMode +import im.vector.app.features.login.SocialLoginButtonsView +import io.reactivex.rxkotlin.subscribeBy +import javax.inject.Inject + +/** + * In this screen: + * - the user is asked for login to sign up to a homeserver. + * - SSO option are displayed if available + */ +class LoginFragment2SignupUsername @Inject constructor() : AbstractSSOLoginFragment2() { + + // Temporary patch for https://github.com/vector-im/riotX-android/issues/1410, + // waiting for https://github.com/matrix-org/synapse/issues/7576 + private var isNumericOnlyUserIdForbidden = false + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLogin2SignupUsernameBinding { + return FragmentLogin2SignupUsernameBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupSubmitButton() + setupAutoFill() + } + + private fun setupAutoFill() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + views.loginField.setAutofillHints(HintConstants.AUTOFILL_HINT_NEW_USERNAME) + } + } + + private fun submit() { + cleanupUi() + + val login = views.loginField.text.toString().trim() + + // This can be called by the IME action, so deal with empty cases + var error = 0 + if (login.isEmpty()) { + views.loginFieldTil.error = getString(R.string.error_empty_field_choose_user_name) + error++ + } + if (isNumericOnlyUserIdForbidden && login.isDigitsOnly()) { + views.loginFieldTil.error = "The homeserver does not accept username with only digits." + error++ + } + + if (error == 0) { + loginViewModel.handle(LoginAction2.SetUserName(login)) + } + } + + private fun cleanupUi() { + views.loginSubmit.hideKeyboard() + views.loginFieldTil.error = null + } + + private fun setupUi(state: LoginViewState2) { + views.loginSubtitle.text = getString(R.string.login_signup_to, state.homeServerUrlFromUser.toReducedUrl()) + + if (state.loginMode is LoginMode.SsoAndPassword) { + views.loginSocialLoginContainer.isVisible = true + views.loginSocialLoginButtons.ssoIdentityProviders = state.loginMode.ssoIdentityProviders + views.loginSocialLoginButtons.listener = object : SocialLoginButtonsView.InteractionListener { + override fun onProviderSelected(id: String?) { + loginViewModel.getSsoUrl( + redirectUrl = LoginActivity2.VECTOR_REDIRECT_URL, + deviceId = state.deviceId, + providerId = id + ) + ?.let { openInCustomTab(it) } + } + } + } else { + views.loginSocialLoginContainer.isVisible = false + views.loginSocialLoginButtons.ssoIdentityProviders = null + } + } + + @SuppressLint("SetTextI18n") + private fun setupSubmitButton() { + views.loginSubmit.setOnClickListener { submit() } + views.loginField.textChanges() + .map { it.trim() } + .subscribeBy { text -> + val isNotEmpty = text.isNotEmpty() + views.loginFieldTil.error = null + views.loginSubmit.isEnabled = isNotEmpty + } + .disposeOnDestroyView() + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction2.ResetLogin) + } + + override fun onError(throwable: Throwable) { + views.loginFieldTil.error = errorFormatter.toHumanReadable(throwable) + } + + @SuppressLint("SetTextI18n") + override fun updateWithState(state: LoginViewState2) { + isNumericOnlyUserIdForbidden = state.isNumericOnlyUserIdForbidden + + setupUi(state) + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginFragmentToAny2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginFragmentToAny2.kt new file mode 100644 index 0000000000..0768c9cdb9 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginFragmentToAny2.kt @@ -0,0 +1,227 @@ +/* + * Copyright 2019 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.login2 + +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import androidx.autofill.HintConstants +import androidx.core.text.isDigitsOnly +import androidx.core.view.isVisible +import com.jakewharton.rxbinding3.widget.textChanges +import im.vector.app.R +import im.vector.app.core.extensions.hideKeyboard +import im.vector.app.core.extensions.showPassword +import im.vector.app.core.extensions.toReducedUrl +import im.vector.app.databinding.FragmentLogin2SigninToBinding +import im.vector.app.features.login.LoginMode +import im.vector.app.features.login.SocialLoginButtonsView +import io.reactivex.Observable +import io.reactivex.rxkotlin.subscribeBy +import org.matrix.android.sdk.api.failure.Failure +import org.matrix.android.sdk.api.failure.MatrixError +import org.matrix.android.sdk.api.failure.isInvalidPassword +import javax.inject.Inject + +/** + * In this screen: + * User want to sign in and has selected a server to do so + * - the user is asked for login (or email) and password to sign in to a homeserver. + * - He also can reset his password + * - It also possible to use SSO if server support it in this screen + */ +class LoginFragmentToAny2 @Inject constructor() : AbstractSSOLoginFragment2() { + + private var passwordShown = false + + // Temporary patch for https://github.com/vector-im/riotX-android/issues/1410, + // waiting for https://github.com/matrix-org/synapse/issues/7576 + private var isNumericOnlyUserIdForbidden = false + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLogin2SigninToBinding { + return FragmentLogin2SigninToBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupSubmitButton() + setupForgottenPasswordButton() + setupPasswordReveal() + setupAutoFill() + + views.passwordField.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + submit() + return@setOnEditorActionListener true + } + return@setOnEditorActionListener false + } + } + + private fun setupForgottenPasswordButton() { + views.forgetPasswordButton.setOnClickListener { forgetPasswordClicked() } + } + + private fun setupAutoFill() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + views.loginField.setAutofillHints(HintConstants.AUTOFILL_HINT_USERNAME) + views.passwordField.setAutofillHints(HintConstants.AUTOFILL_HINT_PASSWORD) + } + views.loginSocialLoginButtons.mode = SocialLoginButtonsView.Mode.MODE_SIGN_IN + } + + private fun submit() { + cleanupUi() + + val login = views.loginField.text.toString() + val password = views.passwordField.text.toString() + + // This can be called by the IME action, so deal with empty cases + var error = 0 + if (login.isEmpty()) { + views.loginFieldTil.error = getString(R.string.error_empty_field_enter_user_name) + error++ + } + if (isNumericOnlyUserIdForbidden && login.isDigitsOnly()) { + views.loginFieldTil.error = "The homeserver does not accept username with only digits." + error++ + } + if (password.isEmpty()) { + views.passwordFieldTil.error = getString(R.string.error_empty_field_your_password) + error++ + } + + if (error == 0) { + loginViewModel.handle(LoginAction2.LoginWith(login, password)) + } + } + + private fun cleanupUi() { + views.loginSubmit.hideKeyboard() + views.loginFieldTil.error = null + views.passwordFieldTil.error = null + } + + private fun setupUi(state: LoginViewState2) { + views.loginTitle.text = getString(R.string.login_connect_to, state.homeServerUrlFromUser.toReducedUrl()) + + if (state.loginMode is LoginMode.SsoAndPassword) { + views.loginSocialLoginContainer.isVisible = true + views.loginSocialLoginButtons.ssoIdentityProviders = state.loginMode.ssoIdentityProviders + views.loginSocialLoginButtons.listener = object : SocialLoginButtonsView.InteractionListener { + override fun onProviderSelected(id: String?) { + loginViewModel.getSsoUrl( + redirectUrl = LoginActivity2.VECTOR_REDIRECT_URL, + deviceId = state.deviceId, + providerId = id + ) + ?.let { openInCustomTab(it) } + } + } + } else { + views.loginSocialLoginContainer.isVisible = false + views.loginSocialLoginButtons.ssoIdentityProviders = null + } + } + + private fun setupSubmitButton() { + views.loginSubmit.setOnClickListener { submit() } + Observable + .combineLatest( + views.loginField.textChanges().map { it.trim().isNotEmpty() }, + views.passwordField.textChanges().map { it.isNotEmpty() }, + { isLoginNotEmpty, isPasswordNotEmpty -> + isLoginNotEmpty && isPasswordNotEmpty + } + ) + .subscribeBy { + views.loginFieldTil.error = null + views.passwordFieldTil.error = null + views.loginSubmit.isEnabled = it + } + .disposeOnDestroyView() + } + + private fun forgetPasswordClicked() { + loginViewModel.handle(LoginAction2.PostViewEvent(LoginViewEvents2.OpenResetPasswordScreen)) + } + + private fun setupPasswordReveal() { + passwordShown = false + + views.passwordReveal.setOnClickListener { + passwordShown = !passwordShown + + renderPasswordField() + } + + renderPasswordField() + } + + private fun renderPasswordField() { + views.passwordField.showPassword(passwordShown) + views.passwordReveal.render(passwordShown) + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction2.ResetLogin) + } + + override fun onError(throwable: Throwable) { + // Show M_WEAK_PASSWORD error in the password field + if (throwable is Failure.ServerError + && throwable.error.code == MatrixError.M_WEAK_PASSWORD) { + views.passwordFieldTil.error = errorFormatter.toHumanReadable(throwable) + } else { + if (throwable is Failure.ServerError + && throwable.error.code == MatrixError.M_FORBIDDEN + && throwable.error.message.isEmpty()) { + // Login with email, but email unknown + views.loginFieldTil.error = getString(R.string.login_login_with_email_error) + } else { + // Trick to display the error without text. + views.loginFieldTil.error = " " + if (throwable.isInvalidPassword() && spaceInPassword()) { + views.passwordFieldTil.error = getString(R.string.auth_invalid_login_param_space_in_password) + } else { + views.passwordFieldTil.error = errorFormatter.toHumanReadable(throwable) + } + } + } + } + + override fun updateWithState(state: LoginViewState2) { + isNumericOnlyUserIdForbidden = state.isNumericOnlyUserIdForbidden + + setupUi(state) + + if (state.isLoading) { + // Ensure password is hidden + passwordShown = false + renderPasswordField() + } + } + + /** + * Detect if password ends or starts with spaces + */ + private fun spaceInPassword() = views.passwordField.text.toString().let { it.trim() != it } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginGenericTextInputFormFragment2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginGenericTextInputFormFragment2.kt new file mode 100644 index 0000000000..d4211de3bb --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginGenericTextInputFormFragment2.kt @@ -0,0 +1,258 @@ +/* + * Copyright 2019 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.login2 + +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import android.text.InputType +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.autofill.HintConstants +import androidx.core.view.isVisible +import com.airbnb.mvrx.args +import com.google.i18n.phonenumbers.NumberParseException +import com.google.i18n.phonenumbers.PhoneNumberUtil +import com.jakewharton.rxbinding3.widget.textChanges +import im.vector.app.R +import im.vector.app.core.extensions.hideKeyboard +import im.vector.app.core.extensions.isEmail +import im.vector.app.core.extensions.setTextOrHide +import im.vector.app.databinding.FragmentLoginGenericTextInputFormBinding +import kotlinx.parcelize.Parcelize +import org.matrix.android.sdk.api.auth.registration.RegisterThreePid +import org.matrix.android.sdk.api.failure.Failure +import org.matrix.android.sdk.api.failure.is401 +import javax.inject.Inject + +enum class TextInputFormFragmentMode { + SetEmail, + SetMsisdn, + ConfirmMsisdn +} + +@Parcelize +data class LoginGenericTextInputFormFragmentArgument( + val mode: TextInputFormFragmentMode, + val mandatory: Boolean, + val extra: String = "" +) : Parcelable + +/** + * In this screen, the user is asked for a text input + */ +class LoginGenericTextInputFormFragment2 @Inject constructor() : AbstractLoginFragment2() { + + private val params: LoginGenericTextInputFormFragmentArgument by args() + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginGenericTextInputFormBinding { + return FragmentLoginGenericTextInputFormBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupViews() + setupUi() + setupSubmitButton() + setupTil() + setupAutoFill() + } + + private fun setupViews() { + views.loginGenericTextInputFormOtherButton.setOnClickListener { onOtherButtonClicked() } + views.loginGenericTextInputFormSubmit.setOnClickListener { submit() } + } + + private fun setupAutoFill() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + views.loginGenericTextInputFormTextInput.setAutofillHints( + when (params.mode) { + TextInputFormFragmentMode.SetEmail -> HintConstants.AUTOFILL_HINT_EMAIL_ADDRESS + TextInputFormFragmentMode.SetMsisdn -> HintConstants.AUTOFILL_HINT_PHONE_NUMBER + TextInputFormFragmentMode.ConfirmMsisdn -> HintConstants.AUTOFILL_HINT_SMS_OTP + } + ) + } + } + + private fun setupTil() { + views.loginGenericTextInputFormTextInput.textChanges() + .subscribe { + views.loginGenericTextInputFormTil.error = null + } + .disposeOnDestroyView() + } + + private fun setupUi() { + when (params.mode) { + TextInputFormFragmentMode.SetEmail -> { + views.loginGenericTextInputFormTitle.text = getString(R.string.login_set_email_title) + views.loginGenericTextInputFormNotice.text = getString(R.string.login_set_email_notice) + views.loginGenericTextInputFormNotice2.setTextOrHide(null) + views.loginGenericTextInputFormTil.hint = + getString(if (params.mandatory) R.string.login_set_email_mandatory_hint else R.string.login_set_email_optional_hint) + views.loginGenericTextInputFormTextInput.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS + views.loginGenericTextInputFormOtherButton.isVisible = false + views.loginGenericTextInputFormSubmit.text = getString(R.string.login_set_email_submit) + } + TextInputFormFragmentMode.SetMsisdn -> { + views.loginGenericTextInputFormTitle.text = getString(R.string.login_set_msisdn_title) + views.loginGenericTextInputFormNotice.text = getString(R.string.login_set_msisdn_notice) + views.loginGenericTextInputFormNotice2.setTextOrHide(getString(R.string.login_set_msisdn_notice2)) + views.loginGenericTextInputFormTil.hint = + getString(if (params.mandatory) R.string.login_set_msisdn_mandatory_hint else R.string.login_set_msisdn_optional_hint) + views.loginGenericTextInputFormTextInput.inputType = InputType.TYPE_CLASS_PHONE + views.loginGenericTextInputFormOtherButton.isVisible = false + views.loginGenericTextInputFormSubmit.text = getString(R.string.login_set_msisdn_submit) + } + TextInputFormFragmentMode.ConfirmMsisdn -> { + views.loginGenericTextInputFormTitle.text = getString(R.string.login_msisdn_confirm_title) + views.loginGenericTextInputFormNotice.text = getString(R.string.login_msisdn_confirm_notice, params.extra) + views.loginGenericTextInputFormNotice2.setTextOrHide(null) + views.loginGenericTextInputFormTil.hint = + getString(R.string.login_msisdn_confirm_hint) + views.loginGenericTextInputFormTextInput.inputType = InputType.TYPE_CLASS_NUMBER + views.loginGenericTextInputFormOtherButton.isVisible = true + views.loginGenericTextInputFormOtherButton.text = getString(R.string.login_msisdn_confirm_send_again) + views.loginGenericTextInputFormSubmit.text = getString(R.string.login_msisdn_confirm_submit) + } + } + } + + private fun onOtherButtonClicked() { + when (params.mode) { + TextInputFormFragmentMode.ConfirmMsisdn -> { + loginViewModel.handle(LoginAction2.SendAgainThreePid) + } + else -> { + // Should not happen, button is not displayed + } + } + } + + private fun submit() { + cleanupUi() + val text = views.loginGenericTextInputFormTextInput.text.toString() + + if (text.isEmpty()) { + // Perform dummy action + loginViewModel.handle(LoginAction2.RegisterDummy) + } else { + when (params.mode) { + TextInputFormFragmentMode.SetEmail -> { + loginViewModel.handle(LoginAction2.AddThreePid(RegisterThreePid.Email(text))) + } + TextInputFormFragmentMode.SetMsisdn -> { + getCountryCodeOrShowError(text)?.let { countryCode -> + loginViewModel.handle(LoginAction2.AddThreePid(RegisterThreePid.Msisdn(text, countryCode))) + } + } + TextInputFormFragmentMode.ConfirmMsisdn -> { + loginViewModel.handle(LoginAction2.ValidateThreePid(text)) + } + } + } + } + + private fun cleanupUi() { + views.loginGenericTextInputFormSubmit.hideKeyboard() + views.loginGenericTextInputFormSubmit.error = null + } + + private fun getCountryCodeOrShowError(text: String): String? { + // We expect an international format for the moment (see https://github.com/vector-im/riotX-android/issues/693) + if (text.startsWith("+")) { + try { + val phoneNumber = PhoneNumberUtil.getInstance().parse(text, null) + return PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(phoneNumber.countryCode) + } catch (e: NumberParseException) { + views.loginGenericTextInputFormTil.error = getString(R.string.login_msisdn_error_other) + } + } else { + views.loginGenericTextInputFormTil.error = getString(R.string.login_msisdn_error_not_international) + } + + // Error + return null + } + + private fun setupSubmitButton() { + views.loginGenericTextInputFormSubmit.isEnabled = false + views.loginGenericTextInputFormTextInput.textChanges() + .subscribe { + views.loginGenericTextInputFormSubmit.isEnabled = isInputValid(it) + } + .disposeOnDestroyView() + } + + private fun isInputValid(input: CharSequence): Boolean { + return if (input.isEmpty() && !params.mandatory) { + true + } else { + when (params.mode) { + TextInputFormFragmentMode.SetEmail -> { + input.isEmail() + } + TextInputFormFragmentMode.SetMsisdn -> { + input.isNotBlank() + } + TextInputFormFragmentMode.ConfirmMsisdn -> { + input.isNotBlank() + } + } + } + } + + override fun onError(throwable: Throwable) { + when (params.mode) { + TextInputFormFragmentMode.SetEmail -> { + if (throwable.is401()) { + // This is normal use case, we go to the mail waiting screen + loginViewModel.handle(LoginAction2.PostViewEvent(LoginViewEvents2.OnSendEmailSuccess(loginViewModel.currentThreePid ?: ""))) + } else { + views.loginGenericTextInputFormTil.error = errorFormatter.toHumanReadable(throwable) + } + } + TextInputFormFragmentMode.SetMsisdn -> { + if (throwable.is401()) { + // This is normal use case, we go to the enter code screen + loginViewModel.handle(LoginAction2.PostViewEvent(LoginViewEvents2.OnSendMsisdnSuccess(loginViewModel.currentThreePid ?: ""))) + } else { + views.loginGenericTextInputFormTil.error = errorFormatter.toHumanReadable(throwable) + } + } + TextInputFormFragmentMode.ConfirmMsisdn -> { + when { + throwable is Failure.SuccessError -> + // The entered code is not correct + views.loginGenericTextInputFormTil.error = getString(R.string.login_validation_code_is_not_correct) + throwable.is401() -> + // It can happen if user request again the 3pid + Unit + else -> + views.loginGenericTextInputFormTil.error = errorFormatter.toHumanReadable(throwable) + } + } + } + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction2.ResetLogin) + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginResetPasswordFragment2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginResetPasswordFragment2.kt new file mode 100644 index 0000000000..3a35e0dc91 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginResetPasswordFragment2.kt @@ -0,0 +1,172 @@ +/* + * Copyright 2019 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.login2 + +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.autofill.HintConstants +import com.jakewharton.rxbinding3.widget.textChanges +import im.vector.app.R +import im.vector.app.core.extensions.hideKeyboard +import im.vector.app.core.extensions.isEmail +import im.vector.app.core.extensions.showPassword +import im.vector.app.core.extensions.toReducedUrl +import im.vector.app.databinding.FragmentLoginResetPassword2Binding +import io.reactivex.Observable +import io.reactivex.rxkotlin.subscribeBy + +import javax.inject.Inject + +/** + * In this screen, the user is asked for email and new password to reset his password + */ +class LoginResetPasswordFragment2 @Inject constructor() : AbstractLoginFragment2() { + + private var passwordsShown = false + + // Show warning only once + private var showWarning = true + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginResetPassword2Binding { + return FragmentLoginResetPassword2Binding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupSubmitButton() + setupPasswordReveal() + setupAutoFill() + } + + private fun setupAutoFill() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + views.resetPasswordEmail.setAutofillHints(HintConstants.AUTOFILL_HINT_EMAIL_ADDRESS) + views.passwordField.setAutofillHints(HintConstants.AUTOFILL_HINT_NEW_PASSWORD) + views.passwordFieldRepeat.setAutofillHints(HintConstants.AUTOFILL_HINT_NEW_PASSWORD) + } + } + + private fun setupUi(state: LoginViewState2) { + views.resetPasswordTitle.text = getString(R.string.login_reset_password_on, state.homeServerUrlFromUser.toReducedUrl()) + } + + private fun setupSubmitButton() { + views.resetPasswordSubmit.setOnClickListener { submit() } + + Observable + .combineLatest( + views.resetPasswordEmail.textChanges().map { it.isEmail() }, + views.passwordField.textChanges().map { it.isNotEmpty() }, + { isEmail, isPasswordNotEmpty -> + isEmail && isPasswordNotEmpty + } + ) + .subscribeBy { + views.resetPasswordEmailTil.error = null + views.passwordFieldTil.error = null + views.resetPasswordSubmit.isEnabled = it + } + .disposeOnDestroyView() + } + + private fun submit() { + cleanupUi() + + var error = 0 + val password = views.passwordField.text.toString() + val passwordRepeat = views.passwordFieldRepeat.text.toString() + + if (password != passwordRepeat) { + views.passwordFieldTilRepeat.error = getString(R.string.auth_password_dont_match) + error++ + } + + if (error > 0) { + return + } + + if (showWarning) { + showWarning = false + // Display a warning as Riot-Web does first + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.login_reset_password_warning_title) + .setMessage(R.string.login_reset_password_warning_content) + .setPositiveButton(R.string.login_reset_password_warning_submit) { _, _ -> + doSubmit() + } + .setNegativeButton(R.string.cancel, null) + .show() + } else { + doSubmit() + } + } + + private fun doSubmit() { + val email = views.resetPasswordEmail.text.toString() + val password = views.passwordField.text.toString() + + loginViewModel.handle(LoginAction2.ResetPassword(email, password)) + } + + private fun cleanupUi() { + views.resetPasswordSubmit.hideKeyboard() + views.resetPasswordEmailTil.error = null + views.passwordFieldTil.error = null + views.passwordFieldTilRepeat.error = null + } + + private fun setupPasswordReveal() { + passwordsShown = false + + views.passwordReveal.setOnClickListener { + passwordsShown = !passwordsShown + + renderPasswordField() + } + + renderPasswordField() + } + + private fun renderPasswordField() { + views.passwordField.showPassword(passwordsShown) + views.passwordFieldRepeat.showPassword(passwordsShown) + views.passwordReveal.render(passwordsShown) + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction2.ResetResetPassword) + } + + override fun onError(throwable: Throwable) { + views.resetPasswordEmailTil.error = errorFormatter.toHumanReadable(throwable) + } + + override fun updateWithState(state: LoginViewState2) { + setupUi(state) + + if (state.isLoading) { + // Ensure new passwords are hidden + passwordsShown = false + renderPasswordField() + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginResetPasswordMailConfirmationFragment2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginResetPasswordMailConfirmationFragment2.kt new file mode 100644 index 0000000000..127d33eb71 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginResetPasswordMailConfirmationFragment2.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2019 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.login2 + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import im.vector.app.R +import im.vector.app.databinding.FragmentLoginResetPasswordMailConfirmationBinding + +import org.matrix.android.sdk.api.failure.is401 +import javax.inject.Inject + +/** + * In this screen, the user is asked to check his email and to click on a button once it's done + */ +class LoginResetPasswordMailConfirmationFragment2 @Inject constructor() : AbstractLoginFragment2() { + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginResetPasswordMailConfirmationBinding { + return FragmentLoginResetPasswordMailConfirmationBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + views.resetPasswordMailConfirmationSubmit.setOnClickListener { submit() } + } + + private fun setupUi(state: LoginViewState2) { + views.resetPasswordMailConfirmationNotice.text = getString(R.string.login_reset_password_mail_confirmation_notice, state.resetPasswordEmail) + } + + private fun submit() { + loginViewModel.handle(LoginAction2.ResetPasswordMailConfirmed) + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction2.ResetResetPassword) + } + + override fun onError(throwable: Throwable) { + // Link in email not yet clicked ? + val message = if (throwable.is401()) { + getString(R.string.auth_reset_password_error_unauthorized) + } else { + errorFormatter.toHumanReadable(throwable) + } + + AlertDialog.Builder(requireActivity()) + .setTitle(R.string.dialog_title_error) + .setMessage(message) + .setPositiveButton(R.string.ok, null) + .show() + } + + override fun updateWithState(state: LoginViewState2) { + setupUi(state) + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginResetPasswordSuccessFragment2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginResetPasswordSuccessFragment2.kt new file mode 100644 index 0000000000..04a1453641 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginResetPasswordSuccessFragment2.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2019 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.login2 + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import im.vector.app.databinding.FragmentLoginResetPasswordSuccess2Binding + +import javax.inject.Inject + +/** + * In this screen, we confirm to the user that his password has been reset + */ +class LoginResetPasswordSuccessFragment2 @Inject constructor() : AbstractLoginFragment2() { + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginResetPasswordSuccess2Binding { + return FragmentLoginResetPasswordSuccess2Binding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + views.resetPasswordSuccessSubmit.setOnClickListener { submit() } + } + + private fun submit() { + loginViewModel.handle(LoginAction2.PostViewEvent(LoginViewEvents2.OnResetPasswordMailConfirmationSuccessDone)) + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction2.ResetResetPassword) + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginServerSelectionFragment2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginServerSelectionFragment2.kt new file mode 100644 index 0000000000..b7a5d64cd4 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginServerSelectionFragment2.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2019 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.login2 + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import im.vector.app.databinding.FragmentLoginServerSelection2Binding +import javax.inject.Inject + +/** + * In this screen, the user will choose between matrix.org, or other type of homeserver + */ +class LoginServerSelectionFragment2 @Inject constructor() : AbstractLoginFragment2() { + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginServerSelection2Binding { + return FragmentLoginServerSelection2Binding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + initViews() + } + + private fun initViews() { + views.loginServerChoiceMatrixOrg.setOnClickListener { selectMatrixOrg() } + views.loginServerChoiceOther.setOnClickListener { selectOther() } + } + + @SuppressLint("SetTextI18n") + private fun updateUi(state: LoginViewState2) { + when (state.signMode) { + SignMode2.Unknown -> Unit + SignMode2.SignUp -> { + views.loginServerTitle.text = "Please choose a server" + } + SignMode2.SignIn -> { + views.loginServerTitle.text = "Please choose your server" + } + } + } + + private fun selectMatrixOrg() { + loginViewModel.handle(LoginAction2.ChooseDefaultHomeServer) + } + + private fun selectOther() { + loginViewModel.handle(LoginAction2.EnterServerUrl) + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction2.ResetHomeServerUrl) + } + + override fun updateWithState(state: LoginViewState2) { + updateUi(state) + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginServerUrlFormFragment2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginServerUrlFormFragment2.kt new file mode 100644 index 0000000000..74bc017128 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginServerUrlFormFragment2.kt @@ -0,0 +1,143 @@ +/* + * Copyright 2019 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.login2 + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.ArrayAdapter +import androidx.core.view.isInvisible +import com.google.android.material.textfield.TextInputLayout +import com.jakewharton.rxbinding3.widget.textChanges +import im.vector.app.BuildConfig +import im.vector.app.R +import im.vector.app.core.extensions.hideKeyboard +import im.vector.app.core.utils.ensureProtocol +import im.vector.app.databinding.FragmentLoginServerUrlForm2Binding +import org.matrix.android.sdk.api.failure.Failure +import org.matrix.android.sdk.api.failure.MatrixError +import java.net.UnknownHostException +import javax.inject.Inject +import javax.net.ssl.HttpsURLConnection + +/** + * In this screen, the user is prompted to enter a homeserver url + */ +class LoginServerUrlFormFragment2 @Inject constructor() : AbstractLoginFragment2() { + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginServerUrlForm2Binding { + return FragmentLoginServerUrlForm2Binding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupViews() + setupHomeServerField() + } + + private fun setupViews() { + views.loginServerUrlFormClearHistory.setOnClickListener { clearHistory() } + views.loginServerUrlFormSubmit.setOnClickListener { submit() } + } + + private fun setupHomeServerField() { + views.loginServerUrlFormHomeServerUrl.textChanges() + .subscribe { + views.loginServerUrlFormHomeServerUrlTil.error = null + views.loginServerUrlFormSubmit.isEnabled = it.isNotBlank() + } + .disposeOnDestroyView() + + views.loginServerUrlFormHomeServerUrl.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + views.loginServerUrlFormHomeServerUrl.dismissDropDown() + submit() + return@setOnEditorActionListener true + } + return@setOnEditorActionListener false + } + } + + private fun setupUi(state: LoginViewState2) { + val completions = state.knownCustomHomeServersUrls + if (BuildConfig.DEBUG) listOf("http://10.0.2.2:8080") else emptyList() + views.loginServerUrlFormHomeServerUrl.setAdapter(ArrayAdapter( + requireContext(), + R.layout.item_completion_homeserver, + completions + )) + views.loginServerUrlFormHomeServerUrlTil.endIconMode = TextInputLayout.END_ICON_DROPDOWN_MENU + .takeIf { completions.isNotEmpty() } + ?: TextInputLayout.END_ICON_NONE + + views.loginServerUrlFormClearHistory.isInvisible = state.knownCustomHomeServersUrls.isEmpty() + } + + private fun clearHistory() { + loginViewModel.handle(LoginAction2.ClearHomeServerHistory) + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction2.ResetHomeServerUrl) + } + + @SuppressLint("SetTextI18n") + private fun submit() { + cleanupUi() + + // Static check of homeserver url, empty, malformed, etc. + val serverUrl = views.loginServerUrlFormHomeServerUrl.text.toString().trim().ensureProtocol() + + when { + serverUrl.isBlank() -> { + views.loginServerUrlFormHomeServerUrlTil.error = getString(R.string.login_error_invalid_home_server) + } + else -> { + views.loginServerUrlFormHomeServerUrl.setText(serverUrl, false /* to avoid completion dialog flicker*/) + loginViewModel.handle(LoginAction2.UpdateHomeServer(serverUrl)) + } + } + } + + private fun cleanupUi() { + views.loginServerUrlFormSubmit.hideKeyboard() + views.loginServerUrlFormHomeServerUrlTil.error = null + } + + override fun onError(throwable: Throwable) { + views.loginServerUrlFormHomeServerUrlTil.error = if (throwable is Failure.NetworkConnection + && throwable.ioException is UnknownHostException) { + // Invalid homeserver? + getString(R.string.login_error_homeserver_not_found) + } else { + if (throwable is Failure.ServerError + && throwable.error.code == MatrixError.M_FORBIDDEN + && throwable.httpCode == HttpsURLConnection.HTTP_FORBIDDEN /* 403 */) { + getString(R.string.login_registration_disabled) + } else { + errorFormatter.toHumanReadable(throwable) + } + } + } + + override fun updateWithState(state: LoginViewState2) { + setupUi(state) + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginSignUpSignInSelectionFragment2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginSignUpSignInSelectionFragment2.kt new file mode 100644 index 0000000000..a5e72bfc18 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginSignUpSignInSelectionFragment2.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2019 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.login2 + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import im.vector.app.BuildConfig +import im.vector.app.databinding.FragmentLoginSplash2Binding +import im.vector.app.features.settings.VectorPreferences +import javax.inject.Inject + +/** + * In this screen, the user is asked to sign up or to sign in to the homeserver + * This is the new splash screen + */ +class LoginSignUpSignInSelectionFragment2 @Inject constructor( + private val vectorPreferences: VectorPreferences +) : AbstractLoginFragment2() { + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginSplash2Binding { + return FragmentLoginSplash2Binding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupViews() + } + + private fun setupViews() { + views.loginSignupSigninSignUp.setOnClickListener { signUp() } + views.loginSignupSigninSignIn.setOnClickListener { signIn() } + + if (BuildConfig.DEBUG || vectorPreferences.developerMode()) { + views.loginSplashVersion.isVisible = true + @SuppressLint("SetTextI18n") + views.loginSplashVersion.text = "Version : ${BuildConfig.VERSION_NAME}\n" + + "Branch: ${BuildConfig.GIT_BRANCH_NAME}\n" + + "Build: ${BuildConfig.BUILD_NUMBER}" + } + } + + private fun signUp() { + loginViewModel.handle(LoginAction2.UpdateSignMode(SignMode2.SignUp)) + } + + private fun signIn() { + loginViewModel.handle(LoginAction2.UpdateSignMode(SignMode2.SignIn)) + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction2.ResetSignMode) + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginSsoOnlyFragment2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginSsoOnlyFragment2.kt new file mode 100644 index 0000000000..edfa02f523 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginSsoOnlyFragment2.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2019 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.login2 + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.airbnb.mvrx.withState +import im.vector.app.R +import im.vector.app.core.extensions.toReducedUrl +import im.vector.app.databinding.FragmentLoginSsoOnly2Binding +import javax.inject.Inject + +/** + * In this screen, the user is asked to sign up or to sign in to the homeserver + */ +class LoginSsoOnlyFragment2 @Inject constructor() : AbstractSSOLoginFragment2() { + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginSsoOnly2Binding { + return FragmentLoginSsoOnly2Binding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupViews() + } + + private fun setupViews() { + views.loginSignupSigninSubmit.setOnClickListener { submit() } + } + + private fun setupUi(state: LoginViewState2) { + views.loginSignupSigninTitle.text = getString(R.string.login_connect_to, state.homeServerUrlFromUser.toReducedUrl()) + } + + private fun submit() = withState(loginViewModel) { state -> + loginViewModel.getSsoUrl( + redirectUrl = LoginActivity2.VECTOR_REDIRECT_URL, + deviceId = state.deviceId, + providerId = null + ) + ?.let { openInCustomTab(it) } + } + + override fun resetViewModel() { + // No op + } + + override fun updateWithState(state: LoginViewState2) { + setupUi(state) + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginViewEvents2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginViewEvents2.kt new file mode 100644 index 0000000000..45e093e1c7 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginViewEvents2.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2019 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.login2 + +import im.vector.app.core.platform.VectorViewEvents +import org.matrix.android.sdk.api.auth.registration.FlowResult + +/** + * Transient events for Login + */ +sealed class LoginViewEvents2 : VectorViewEvents { + data class Failure(val throwable: Throwable) : LoginViewEvents2() + + data class RegistrationFlowResult(val flowResult: FlowResult, val isRegistrationStarted: Boolean) : LoginViewEvents2() + object OutdatedHomeserver : LoginViewEvents2() + + // Navigation event + object OpenPasswordScreen : LoginViewEvents2() + object OpenSignupPasswordScreen : LoginViewEvents2() + + object OpenSignInEnterIdentifierScreen : LoginViewEvents2() + + object OpenSignUpChooseUsernameScreen : LoginViewEvents2() + object OpenSignInWithAnythingScreen : LoginViewEvents2() + + object OpenSsoOnlyScreen : LoginViewEvents2() + + object OpenServerSelection : LoginViewEvents2() + object OpenHomeServerUrlFormScreen : LoginViewEvents2() + + object OpenResetPasswordScreen : LoginViewEvents2() + object OnResetPasswordSendThreePidDone : LoginViewEvents2() + object OnResetPasswordMailConfirmationSuccess : LoginViewEvents2() + object OnResetPasswordMailConfirmationSuccessDone : LoginViewEvents2() + + data class OnLoginModeNotSupported(val supportedTypes: List) : LoginViewEvents2() + + data class OnSendEmailSuccess(val email: String) : LoginViewEvents2() + data class OnSendMsisdnSuccess(val msisdn: String) : LoginViewEvents2() + + data class OnWebLoginError(val errorCode: Int, val description: String, val failingUrl: String) : LoginViewEvents2() + + data class OnSessionCreated(val newAccount: Boolean): LoginViewEvents2() +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginViewModel2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginViewModel2.kt new file mode 100644 index 0000000000..daa9631041 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginViewModel2.kt @@ -0,0 +1,828 @@ +/* + * Copyright 2019 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.login2 + +import android.content.Context +import android.net.Uri +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.viewModelScope +import com.airbnb.mvrx.ActivityViewModelContext +import com.airbnb.mvrx.MvRxViewModelFactory +import com.airbnb.mvrx.ViewModelContext +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import im.vector.app.R +import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.core.extensions.configureAndStart +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.ensureTrailingSlash +import im.vector.app.features.login.HomeServerConnectionConfigFactory +import im.vector.app.features.login.LoginConfig +import im.vector.app.features.login.LoginMode +import im.vector.app.features.login.ReAuthHelper +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.auth.AuthenticationService +import org.matrix.android.sdk.api.auth.HomeServerHistoryService +import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig +import org.matrix.android.sdk.api.auth.data.LoginFlowResult +import org.matrix.android.sdk.api.auth.data.LoginFlowTypes +import org.matrix.android.sdk.api.auth.login.LoginWizard +import org.matrix.android.sdk.api.auth.registration.FlowResult +import org.matrix.android.sdk.api.auth.registration.RegistrationAvailability +import org.matrix.android.sdk.api.auth.registration.RegistrationResult +import org.matrix.android.sdk.api.auth.registration.RegistrationWizard +import org.matrix.android.sdk.api.auth.registration.Stage +import org.matrix.android.sdk.api.auth.wellknown.WellknownResult +import org.matrix.android.sdk.api.session.Session +import timber.log.Timber +import java.util.concurrent.CancellationException + +/** + * + */ +class LoginViewModel2 @AssistedInject constructor( + @Assisted initialState: LoginViewState2, + private val applicationContext: Context, + private val authenticationService: AuthenticationService, + private val activeSessionHolder: ActiveSessionHolder, + private val homeServerConnectionConfigFactory: HomeServerConnectionConfigFactory, + private val reAuthHelper: ReAuthHelper, + private val stringProvider: StringProvider, + private val homeServerHistoryService: HomeServerHistoryService +) : VectorViewModel(initialState) { + + @AssistedFactory + interface Factory { + fun create(initialState: LoginViewState2): LoginViewModel2 + } + + init { + getKnownCustomHomeServersUrls() + } + + private fun getKnownCustomHomeServersUrls() { + setState { + copy(knownCustomHomeServersUrls = homeServerHistoryService.getKnownServersUrls()) + } + } + + companion object : MvRxViewModelFactory { + + @JvmStatic + override fun create(viewModelContext: ViewModelContext, state: LoginViewState2): LoginViewModel2? { + return when (val activity: FragmentActivity = (viewModelContext as ActivityViewModelContext).activity()) { + is LoginActivity2 -> activity.loginViewModelFactory.create(state) + // TODO is SoftLogoutActivity -> activity.loginViewModelFactory.create(state) + else -> error("Invalid Activity") + } + } + } + + // Store the last action, to redo it after user has trusted the untrusted certificate + private var lastAction: LoginAction2? = null + private var currentHomeServerConnectionConfig: HomeServerConnectionConfig? = null + + private val matrixOrgUrl = stringProvider.getString(R.string.matrix_org_server_url).ensureTrailingSlash() + + val currentThreePid: String? + get() = registrationWizard?.currentThreePid + + // True when login and password has been sent with success to the homeserver + val isRegistrationStarted: Boolean + get() = authenticationService.isRegistrationStarted + + private val registrationWizard: RegistrationWizard? + get() = authenticationService.getRegistrationWizard() + + private val loginWizard: LoginWizard? + get() = authenticationService.getLoginWizard() + + private var loginConfig: LoginConfig? = null + + private var currentJob: Job? = null + set(value) { + // Cancel any previous Job + field?.cancel() + field = value + } + + override fun handle(action: LoginAction2) { + when (action) { + is LoginAction2.EnterServerUrl -> handleEnterServerUrl() + is LoginAction2.ChooseAServerForSignin -> handleChooseAServerForSignin() + is LoginAction2.UpdateSignMode -> handleUpdateSignMode(action) + is LoginAction2.InitWith -> handleInitWith(action) + is LoginAction2.ChooseDefaultHomeServer -> handle(LoginAction2.UpdateHomeServer(matrixOrgUrl)) + is LoginAction2.UpdateHomeServer -> handleUpdateHomeserver(action).also { lastAction = action } + is LoginAction2.SetUserName -> handleSetUserName(action).also { lastAction = action } + is LoginAction2.SetUserPassword -> handleSetUserPassword(action).also { lastAction = action } + is LoginAction2.LoginWith -> handleLoginWith(action).also { lastAction = action } + is LoginAction2.LoginWithToken -> handleLoginWithToken(action) + is LoginAction2.WebLoginSuccess -> handleWebLoginSuccess(action) + is LoginAction2.ResetPassword -> handleResetPassword(action) + is LoginAction2.ResetPasswordMailConfirmed -> handleResetPasswordMailConfirmed() + is LoginAction2.RegisterAction -> handleRegisterAction(action) + is LoginAction2.ResetAction -> handleResetAction(action) + is LoginAction2.SetupSsoForSessionRecovery -> handleSetupSsoForSessionRecovery(action) + is LoginAction2.UserAcceptCertificate -> handleUserAcceptCertificate(action) + LoginAction2.ClearHomeServerHistory -> handleClearHomeServerHistory() + is LoginAction2.PostViewEvent -> _viewEvents.post(action.viewEvent) + }.exhaustive + } + + private fun handleChooseAServerForSignin() { + // Just post a view Event + _viewEvents.post(LoginViewEvents2.OpenServerSelection) + } + + private fun handleUserAcceptCertificate(action: LoginAction2.UserAcceptCertificate) { + // It happens when we get the login flow, or during direct authentication. + // So alter the homeserver config and retrieve again the login flow + when (val finalLastAction = lastAction) { + is LoginAction2.UpdateHomeServer -> { + currentHomeServerConnectionConfig + ?.let { it.copy(allowedFingerprints = it.allowedFingerprints + action.fingerprint) } + ?.let { getLoginFlow(it) } + } + is LoginAction2.SetUserName -> + handleSetUserNameForSignIn( + finalLastAction, + HomeServerConnectionConfig.Builder() + // Will be replaced by the task + .withHomeServerUri("https://dummy.org") + .withAllowedFingerPrints(listOf(action.fingerprint)) + .build() + ) + is LoginAction2.SetUserPassword -> + handleSetUserPassword(finalLastAction) + is LoginAction2.LoginWith -> + handleLoginWith(finalLastAction) + } + } + + private fun rememberHomeServer(homeServerUrl: String) { + homeServerHistoryService.addHomeServerToHistory(homeServerUrl) + getKnownCustomHomeServersUrls() + } + + private fun handleClearHomeServerHistory() { + homeServerHistoryService.clearHistory() + getKnownCustomHomeServersUrls() + } + + private fun handleLoginWithToken(action: LoginAction2.LoginWithToken) { + val safeLoginWizard = loginWizard + + if (safeLoginWizard == null) { + _viewEvents.post(LoginViewEvents2.Failure(Throwable("Bad configuration"))) + } else { + setState { copy(isLoading = true) } + + currentJob = viewModelScope.launch { + try { + safeLoginWizard.loginWithToken(action.loginToken) + } catch (failure: Throwable) { + _viewEvents.post(LoginViewEvents2.Failure(failure)) + null + } + ?.let { onSessionCreated(it) } + + setState { copy(isLoading = false) } + } + } + } + + private fun handleSetupSsoForSessionRecovery(action: LoginAction2.SetupSsoForSessionRecovery) { + setState { + copy( + signMode = SignMode2.SignIn, + loginMode = LoginMode.Sso(action.ssoIdentityProviders), + homeServerUrlFromUser = action.homeServerUrl, + homeServerUrl = action.homeServerUrl, + isNumericOnlyUserIdForbidden = action.homeServerUrl == matrixOrgUrl, + deviceId = action.deviceId + ) + } + } + + private fun handleRegisterAction(action: LoginAction2.RegisterAction) { + when (action) { + is LoginAction2.CaptchaDone -> handleCaptchaDone(action) + is LoginAction2.AcceptTerms -> handleAcceptTerms() + is LoginAction2.RegisterDummy -> handleRegisterDummy() + is LoginAction2.AddThreePid -> handleAddThreePid(action) + is LoginAction2.SendAgainThreePid -> handleSendAgainThreePid() + is LoginAction2.ValidateThreePid -> handleValidateThreePid(action) + is LoginAction2.CheckIfEmailHasBeenValidated -> handleCheckIfEmailHasBeenValidated(action) + is LoginAction2.StopEmailValidationCheck -> handleStopEmailValidationCheck() + } + } + + private fun handleCheckIfEmailHasBeenValidated(action: LoginAction2.CheckIfEmailHasBeenValidated) { + // We do not want the common progress bar to be displayed, so we do not change asyncRegistration value in the state + currentJob = executeRegistrationStep(withLoading = false) { + it.checkIfEmailHasBeenValidated(action.delayMillis) + } + } + + private fun handleStopEmailValidationCheck() { + currentJob = null + } + + private fun handleValidateThreePid(action: LoginAction2.ValidateThreePid) { + currentJob = executeRegistrationStep { + it.handleValidateThreePid(action.code) + } + } + + private fun executeRegistrationStep(withLoading: Boolean = true, + block: suspend (RegistrationWizard) -> RegistrationResult): Job { + if (withLoading) { + setState { copy(isLoading = true) } + } + return viewModelScope.launch { + try { + registrationWizard?.let { block(it) } + } catch (failure: Throwable) { + if (failure !is CancellationException) { + _viewEvents.post(LoginViewEvents2.Failure(failure)) + } + null + } + ?.let { data -> + when (data) { + is RegistrationResult.Success -> onSessionCreated(data.session) + is RegistrationResult.FlowResponse -> onFlowResponse(data.flowResult) + } + } + + setState { copy(isLoading = false) } + } + } + + private fun handleAddThreePid(action: LoginAction2.AddThreePid) { + setState { copy(isLoading = true) } + currentJob = viewModelScope.launch { + try { + registrationWizard?.addThreePid(action.threePid) + } catch (failure: Throwable) { + _viewEvents.post(LoginViewEvents2.Failure(failure)) + } + setState { copy(isLoading = false) } + } + } + + private fun handleSendAgainThreePid() { + setState { copy(isLoading = true) } + currentJob = viewModelScope.launch { + try { + registrationWizard?.sendAgainThreePid() + } catch (failure: Throwable) { + _viewEvents.post(LoginViewEvents2.Failure(failure)) + } + setState { copy(isLoading = false) } + } + } + + private fun handleAcceptTerms() { + currentJob = executeRegistrationStep { + it.acceptTerms() + } + } + + private fun handleRegisterDummy() { + currentJob = executeRegistrationStep { + it.dummy() + } + } + + /** + * Check that the user name is available + */ + private fun handleSetUserNameForSignUp(action: LoginAction2.SetUserName) { + setState { copy(isLoading = true) } + + val safeRegistrationWizard = registrationWizard ?: error("Invalid") + + viewModelScope.launch { + val available = safeRegistrationWizard.registrationAvailable(action.username) + + val event = when (available) { + RegistrationAvailability.Available -> { + // Ask for a password + LoginViewEvents2.OpenSignupPasswordScreen + } + is RegistrationAvailability.NotAvailable -> { + LoginViewEvents2.Failure(available.failure) + } + } + _viewEvents.post(event) + setState { copy(isLoading = false) } + } + } + + private fun handleCaptchaDone(action: LoginAction2.CaptchaDone) { + currentJob = executeRegistrationStep { + it.performReCaptcha(action.captchaResponse) + } + } + + // TODO Update this + private fun handleResetAction(action: LoginAction2.ResetAction) { + // Cancel any request + currentJob = null + + when (action) { + LoginAction2.ResetHomeServerUrl -> { + viewModelScope.launch { + authenticationService.reset() + setState { + copy( + homeServerUrlFromUser = null, + homeServerUrl = null, + loginMode = LoginMode.Unknown + ) + } + } + } + LoginAction2.ResetSignMode -> { + setState { + copy( + signMode = SignMode2.Unknown, + loginMode = LoginMode.Unknown + ) + } + } + LoginAction2.ResetLogin -> { + viewModelScope.launch { + authenticationService.cancelPendingLoginOrRegistration() + setState { copy(isLoading = false) } + } + } + LoginAction2.ResetResetPassword -> { + setState { + copy( + resetPasswordEmail = null + ) + } + } + } + } + + private fun handleUpdateSignMode(action: LoginAction2.UpdateSignMode) { + setState { + // Always create a new state, to ensure the state is correctly reset + LoginViewState2( + signMode = action.signMode + ) + } + + when (action.signMode) { + SignMode2.SignUp -> _viewEvents.post(LoginViewEvents2.OpenServerSelection) + SignMode2.SignIn -> _viewEvents.post(LoginViewEvents2.OpenSignInEnterIdentifierScreen) + SignMode2.Unknown -> Unit + } + } + + private fun handleEnterServerUrl() { + _viewEvents.post(LoginViewEvents2.OpenHomeServerUrlFormScreen) + } + + private fun handleInitWith(action: LoginAction2.InitWith) { + loginConfig = action.loginConfig + + // If there is a pending email validation continue on this step + try { + if (registrationWizard?.isRegistrationStarted == true) { + currentThreePid?.let { + handle(LoginAction2.PostViewEvent(LoginViewEvents2.OnSendEmailSuccess(it))) + } + } + } catch (e: Throwable) { + // NOOP. API is designed to use wizards in a login/registration flow, + // but we need to check the state anyway. + } + } + + private fun handleResetPassword(action: LoginAction2.ResetPassword) { + val safeLoginWizard = loginWizard + + if (safeLoginWizard == null) { + _viewEvents.post(LoginViewEvents2.Failure(Throwable("Bad configuration"))) + } else { + setState { copy(isLoading = true) } + + currentJob = viewModelScope.launch { + try { + safeLoginWizard.resetPassword(action.email, action.newPassword) + } catch (failure: Throwable) { + _viewEvents.post(LoginViewEvents2.Failure(failure)) + setState { copy(isLoading = false) } + return@launch + } + + setState { + copy( + isLoading = false, + resetPasswordEmail = action.email + ) + } + + _viewEvents.post(LoginViewEvents2.OnResetPasswordSendThreePidDone) + } + } + } + + private fun handleResetPasswordMailConfirmed() { + val safeLoginWizard = loginWizard + + if (safeLoginWizard == null) { + _viewEvents.post(LoginViewEvents2.Failure(Throwable("Bad configuration"))) + } else { + setState { copy(isLoading = true) } + + currentJob = viewModelScope.launch { + try { + safeLoginWizard.resetPasswordMailConfirmed() + } catch (failure: Throwable) { + _viewEvents.post(LoginViewEvents2.Failure(failure)) + setState { copy(isLoading = false) } + return@launch + } + setState { + copy( + isLoading = false, + resetPasswordEmail = null + ) + } + + _viewEvents.post(LoginViewEvents2.OnResetPasswordMailConfirmationSuccess) + } + } + } + + private fun handleSetUserName(action: LoginAction2.SetUserName) = withState { state -> + setState { + copy( + userName = action.username + ) + } + + when (state.signMode) { + SignMode2.Unknown -> error("Developer error, invalid sign mode") + SignMode2.SignIn -> handleSetUserNameForSignIn(action, null) + SignMode2.SignUp -> handleSetUserNameForSignUp(action) + }.exhaustive + } + + private fun handleSetUserPassword(action: LoginAction2.SetUserPassword) = withState { state -> + when (state.signMode) { + SignMode2.Unknown -> error("Developer error, invalid sign mode") + SignMode2.SignIn -> handleSignInWithPassword(action) + SignMode2.SignUp -> handleRegisterWithPassword(action) + }.exhaustive + } + + private fun handleRegisterWithPassword(action: LoginAction2.SetUserPassword) = withState { state -> + val username = state.userName ?: error("Developer error, username not set") + + reAuthHelper.data = action.password + currentJob = executeRegistrationStep { + it.createAccount( + userName = username, + password = action.password, + initialDeviceDisplayName = stringProvider.getString(R.string.login_default_session_public_name) + ) + } + } + + private fun handleSignInWithPassword(action: LoginAction2.SetUserPassword) = withState { state -> + val username = state.userName ?: error("Developer error, username not set") + setState { copy(isLoading = true) } + loginWith(username, action.password) + } + + private fun handleLoginWith(action: LoginAction2.LoginWith) { + setState { copy(isLoading = true) } + loginWith(action.login, action.password) + } + + private fun loginWith(login: String, password: String) { + val safeLoginWizard = loginWizard + + if (safeLoginWizard == null) { + _viewEvents.post(LoginViewEvents2.Failure(Throwable("Bad configuration"))) + setState { copy(isLoading = false) } + } else { + currentJob = viewModelScope.launch { + try { + safeLoginWizard.login( + login = login, + password = password, + deviceName = stringProvider.getString(R.string.login_default_session_public_name) + ) + } catch (failure: Throwable) { + _viewEvents.post(LoginViewEvents2.Failure(failure)) + null + } + ?.let { + reAuthHelper.data = password + onSessionCreated(it) + } + setState { copy(isLoading = false) } + } + } + } + + /** + * Perform wellknown request + */ + private fun handleSetUserNameForSignIn(action: LoginAction2.SetUserName, homeServerConnectionConfig: HomeServerConnectionConfig?) { + setState { copy(isLoading = true) } + + currentJob = viewModelScope.launch { + val data = try { + authenticationService.getWellKnownData(action.username, homeServerConnectionConfig) + } catch (failure: Throwable) { + onDirectLoginError(failure) + return@launch + } + when (data) { + is WellknownResult.Prompt -> + onWellknownSuccess(action, data, homeServerConnectionConfig) + is WellknownResult.FailPrompt -> + // Relax on IS discovery if home server is valid + if (data.homeServerUrl != null && data.wellKnown != null) { + onWellknownSuccess(action, WellknownResult.Prompt(data.homeServerUrl!!, null, data.wellKnown!!), homeServerConnectionConfig) + } else { + onWellKnownError() + } + is WellknownResult.InvalidMatrixId -> { + setState { copy(isLoading = false) } + _viewEvents.post(LoginViewEvents2.Failure(Exception(stringProvider.getString(R.string.login_signin_matrix_id_error_invalid_matrix_id)))) + } + else -> { + onWellKnownError() + } + }.exhaustive + } + } + + private fun onWellKnownError() { + _viewEvents.post(LoginViewEvents2.Failure(Exception(stringProvider.getString(R.string.autodiscover_well_known_error)))) + setState { copy(isLoading = false) } + } + + private suspend fun onWellknownSuccess(action: LoginAction2.SetUserName, + wellKnownPrompt: WellknownResult.Prompt, + homeServerConnectionConfig: HomeServerConnectionConfig?) { + val alteredHomeServerConnectionConfig = homeServerConnectionConfig + ?.copy( + homeServerUri = Uri.parse(wellKnownPrompt.homeServerUrl), + identityServerUri = wellKnownPrompt.identityServerUrl?.let { Uri.parse(it) } + ) + ?: HomeServerConnectionConfig( + homeServerUri = Uri.parse(wellKnownPrompt.homeServerUrl), + identityServerUri = wellKnownPrompt.identityServerUrl?.let { Uri.parse(it) } + ) + + // Ensure login flow is retrieved, and this is not a SSO only server + val data = try { + authenticationService.getLoginFlow(alteredHomeServerConnectionConfig) + } catch (failure: Throwable) { + _viewEvents.post(LoginViewEvents2.Failure(failure)) + null + } + + if (data is LoginFlowResult.Success) { + val loginMode = when { + data.supportedLoginTypes.contains(LoginFlowTypes.SSO) + && data.supportedLoginTypes.contains(LoginFlowTypes.PASSWORD) -> LoginMode.SsoAndPassword(data.ssoIdentityProviders) + data.supportedLoginTypes.contains(LoginFlowTypes.SSO) -> LoginMode.Sso(data.ssoIdentityProviders) + data.supportedLoginTypes.contains(LoginFlowTypes.PASSWORD) -> LoginMode.Password + else -> LoginMode.Unsupported + } + + val viewEvent = when (loginMode) { + LoginMode.Password, + is LoginMode.SsoAndPassword -> { + retrieveProfileInfo(action.username) + // We can navigate to the password screen + LoginViewEvents2.OpenPasswordScreen + } + is LoginMode.Sso -> { + LoginViewEvents2.OpenSsoOnlyScreen + } + LoginMode.Unsupported -> LoginViewEvents2.OnLoginModeNotSupported(data.supportedLoginTypes.toList()) + LoginMode.Unknown -> null + } + viewEvent?.let { _viewEvents.post(it) } + + val urlFromUser = action.username.substringAfter(":") + setState { + copy( + isLoading = false, + homeServerUrlFromUser = urlFromUser, + homeServerUrl = data.homeServerUrl, + isNumericOnlyUserIdForbidden = urlFromUser == matrixOrgUrl, + loginMode = loginMode + ) + } + + if ((loginMode == LoginMode.Password && !data.isLoginAndRegistrationSupported) + || data.isOutdatedHomeserver) { + // Notify the UI + _viewEvents.post(LoginViewEvents2.OutdatedHomeserver) + } + } + } + + private suspend fun retrieveProfileInfo(username: String) { + val safeLoginWizard = loginWizard + + if (safeLoginWizard != null) { + try { + val info = safeLoginWizard.getProfileInfo(username) + setState { + copy( + loginProfileInfo = info + ) + } + } catch (failure: Throwable) { + // Ignore error + // TODO 404 may indicates that the user does not exist, so there is a mistake in the id + } + } + } + + private fun onDirectLoginError(failure: Throwable) { + _viewEvents.post(LoginViewEvents2.Failure(failure)) + setState { copy(isLoading = false) } + } + + private fun onFlowResponse(flowResult: FlowResult) { + // If dummy stage is mandatory, and password is already sent, do the dummy stage now + if (isRegistrationStarted + && flowResult.missingStages.any { it is Stage.Dummy && it.mandatory }) { + handleRegisterDummy() + } else { + // Notify the user + _viewEvents.post(LoginViewEvents2.RegistrationFlowResult(flowResult, isRegistrationStarted)) + } + } + + private suspend fun onSessionCreated(session: Session) { + activeSessionHolder.setActiveSession(session) + + authenticationService.reset() + session.configureAndStart(applicationContext) + withState { state -> + _viewEvents.post(LoginViewEvents2.OnSessionCreated(state.signMode == SignMode2.SignUp)) + } + } + + private fun handleWebLoginSuccess(action: LoginAction2.WebLoginSuccess) = withState { state -> + val homeServerConnectionConfigFinal = homeServerConnectionConfigFactory.create(state.homeServerUrl) + + if (homeServerConnectionConfigFinal == null) { + // Should not happen + Timber.w("homeServerConnectionConfig is null") + } else { + currentJob = viewModelScope.launch { + try { + authenticationService.createSessionFromSso(homeServerConnectionConfigFinal, action.credentials) + } catch (failure: Throwable) { + _viewEvents.post(LoginViewEvents2.Failure(failure)) + null + } + ?.let { onSessionCreated(it) } + } + } + } + + private fun handleUpdateHomeserver(action: LoginAction2.UpdateHomeServer) { + val homeServerConnectionConfig = homeServerConnectionConfigFactory.create(action.homeServerUrl) + if (homeServerConnectionConfig == null) { + // This is invalid + _viewEvents.post(LoginViewEvents2.Failure(Throwable("Unable to create a HomeServerConnectionConfig"))) + } else { + getLoginFlow(homeServerConnectionConfig) + } + } + + private fun getLoginFlow(homeServerConnectionConfig: HomeServerConnectionConfig) = withState { state -> + currentHomeServerConnectionConfig = homeServerConnectionConfig + + setState { copy(isLoading = true) } + + currentJob = viewModelScope.launch { + authenticationService.cancelPendingLoginOrRegistration() + + val data = try { + authenticationService.getLoginFlow(homeServerConnectionConfig) + } catch (failure: Throwable) { + _viewEvents.post(LoginViewEvents2.Failure(failure)) + setState { copy(isLoading = false) } + null + } + + if (data is LoginFlowResult.Success) { + // Valid Homeserver, add it to the history. + // Note: we add what the user has input, data.homeServerUrl can be different + rememberHomeServer(homeServerConnectionConfig.homeServerUri.toString()) + + val loginMode = when { + data.supportedLoginTypes.contains(LoginFlowTypes.SSO) + && data.supportedLoginTypes.contains(LoginFlowTypes.PASSWORD) -> LoginMode.SsoAndPassword(data.ssoIdentityProviders) + data.supportedLoginTypes.contains(LoginFlowTypes.SSO) -> LoginMode.Sso(data.ssoIdentityProviders) + data.supportedLoginTypes.contains(LoginFlowTypes.PASSWORD) -> LoginMode.Password + else -> LoginMode.Unsupported + } + + val viewEvent = when (loginMode) { + LoginMode.Password, + is LoginMode.SsoAndPassword -> { + when (state.signMode) { + SignMode2.Unknown -> null + SignMode2.SignUp -> { + // Check that registration is possible on this server + try { + registrationWizard?.getRegistrationFlow() + + /* + // Simulate registration disabled + throw Failure.ServerError( + error = MatrixError( + code = MatrixError.M_FORBIDDEN, + message = "Registration is disabled" + ), + httpCode = 403 + ) + */ + + LoginViewEvents2.OpenSignUpChooseUsernameScreen + } catch (throwable: Throwable) { + // Registration disabled? + LoginViewEvents2.Failure(throwable) + } + } + SignMode2.SignIn -> LoginViewEvents2.OpenSignInWithAnythingScreen + } + } + is LoginMode.Sso -> { + LoginViewEvents2.OpenSsoOnlyScreen + } + LoginMode.Unsupported -> LoginViewEvents2.OnLoginModeNotSupported(data.supportedLoginTypes.toList()) + LoginMode.Unknown -> null + } + viewEvent?.let { _viewEvents.post(it) } + + if ((loginMode == LoginMode.Password && !data.isLoginAndRegistrationSupported) + || data.isOutdatedHomeserver) { + // Notify the UI + _viewEvents.post(LoginViewEvents2.OutdatedHomeserver) + } + + setState { + copy( + isLoading = false, + homeServerUrlFromUser = homeServerConnectionConfig.homeServerUri.toString(), + homeServerUrl = data.homeServerUrl, + isNumericOnlyUserIdForbidden = homeServerConnectionConfig.homeServerUri.toString() == matrixOrgUrl, + loginMode = loginMode + ) + } + } + } + } + + fun getInitialHomeServerUrl(): String? { + return loginConfig?.homeServerUrl + } + + fun getSsoUrl(redirectUrl: String, deviceId: String?, providerId: String?): String? { + return authenticationService.getSsoUrl(redirectUrl, deviceId, providerId) + } + + fun getFallbackUrl(forSignIn: Boolean, deviceId: String?): String? { + return authenticationService.getFallbackUrl(forSignIn, deviceId) + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginViewState2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginViewState2.kt new file mode 100644 index 0000000000..3c631af18c --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginViewState2.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2019 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.login2 + +import com.airbnb.mvrx.MvRxState +import com.airbnb.mvrx.PersistState +import im.vector.app.core.extensions.toReducedUrl +import im.vector.app.features.login.LoginMode +import org.matrix.android.sdk.api.MatrixPatterns +import org.matrix.android.sdk.api.auth.login.LoginProfileInfo + +data class LoginViewState2( + val isLoading: Boolean = false, + + // User choices + @PersistState + val signMode: SignMode2 = SignMode2.Unknown, + @PersistState + val userName: String? = null, + @PersistState + val resetPasswordEmail: String? = null, + @PersistState + val homeServerUrlFromUser: String? = null, + + // Can be modified after a Wellknown request + @PersistState + val homeServerUrl: String? = null, + + // For SSO session recovery + @PersistState + val deviceId: String? = null, + + // Network result + val loginProfileInfo: LoginProfileInfo? = null, + + // True on Matrix.org + val isNumericOnlyUserIdForbidden: Boolean = false, + + // Network result + @PersistState + val loginMode: LoginMode = LoginMode.Unknown, + + // From database + val knownCustomHomeServersUrls: List = emptyList() +) : MvRxState { + + // Pending user identifier + fun userIdentifier(): String { + return if (userName != null && MatrixPatterns.isUserId(userName)) { + userName + } else { + "@$userName:${homeServerUrlFromUser.toReducedUrl()}" + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginWaitForEmailFragment2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginWaitForEmailFragment2.kt new file mode 100644 index 0000000000..db3c607480 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginWaitForEmailFragment2.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2019 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.login2 + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.airbnb.mvrx.args +import im.vector.app.R +import im.vector.app.databinding.FragmentLoginWaitForEmail2Binding +import im.vector.app.features.login.LoginWaitForEmailFragmentArgument +import org.matrix.android.sdk.api.failure.is401 +import javax.inject.Inject + +/** + * In this screen, the user is asked to check his emails + */ +class LoginWaitForEmailFragment2 @Inject constructor() : AbstractLoginFragment2() { + + private val params: LoginWaitForEmailFragmentArgument by args() + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginWaitForEmail2Binding { + return FragmentLoginWaitForEmail2Binding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupUi() + } + + override fun onResume() { + super.onResume() + + loginViewModel.handle(LoginAction2.CheckIfEmailHasBeenValidated(0)) + } + + override fun onPause() { + super.onPause() + + loginViewModel.handle(LoginAction2.StopEmailValidationCheck) + } + + private fun setupUi() { + views.loginWaitForEmailNotice.text = getString(R.string.login_wait_for_email_notice_2, params.email) + } + + override fun onError(throwable: Throwable) { + if (throwable.is401()) { + // Try again, with a delay + loginViewModel.handle(LoginAction2.CheckIfEmailHasBeenValidated(10_000)) + } else { + super.onError(throwable) + } + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction2.ResetLogin) + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/LoginWebFragment2.kt b/vector/src/main/java/im/vector/app/features/login2/LoginWebFragment2.kt new file mode 100644 index 0000000000..2acb30bd8a --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/LoginWebFragment2.kt @@ -0,0 +1,255 @@ +/* + * Copyright 2019 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. + */ + +@file:Suppress("DEPRECATION") + +package im.vector.app.features.login2 + +import android.annotation.SuppressLint +import android.content.DialogInterface +import android.graphics.Bitmap +import android.net.http.SslError +import android.os.Bundle +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.webkit.SslErrorHandler +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.appcompat.app.AlertDialog +import com.airbnb.mvrx.activityViewModel +import im.vector.app.R +import im.vector.app.core.utils.AssetReader +import im.vector.app.databinding.FragmentLoginWebBinding +import im.vector.app.features.login.JavascriptResponse +import im.vector.app.features.signout.soft.SoftLogoutAction +import im.vector.app.features.signout.soft.SoftLogoutViewModel + +import org.matrix.android.sdk.api.auth.data.Credentials +import org.matrix.android.sdk.internal.di.MoshiProvider +import timber.log.Timber +import java.net.URLDecoder +import javax.inject.Inject + +/** + * This screen is displayed when the application does not support login flow or registration flow + * of the homeserver, as a fallback to login or to create an account + */ +class LoginWebFragment2 @Inject constructor( + private val assetReader: AssetReader +) : AbstractLoginFragment2() { + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginWebBinding { + return FragmentLoginWebBinding.inflate(inflater, container, false) + } + + private var isWebViewLoaded = false + private var isForSessionRecovery = false + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupToolbar(views.loginWebToolbar) + } + + override fun updateWithState(state: LoginViewState2) { + setupTitle(state) + + isForSessionRecovery = state.deviceId?.isNotBlank() == true + + if (!isWebViewLoaded) { + setupWebView(state) + isWebViewLoaded = true + } + } + + private fun setupTitle(state: LoginViewState2) { + views.loginWebToolbar.title = when (state.signMode) { + SignMode2.SignIn -> getString(R.string.login_signin) + else -> getString(R.string.login_signup) + } + } + + @SuppressLint("SetJavaScriptEnabled") + private fun setupWebView(state: LoginViewState2) { + views.loginWebWebView.settings.javaScriptEnabled = true + + // Enable local storage to support SSO with Firefox accounts + views.loginWebWebView.settings.domStorageEnabled = true + views.loginWebWebView.settings.databaseEnabled = true + + // Due to https://developers.googleblog.com/2016/08/modernizing-oauth-interactions-in-native-apps.html, we hack + // the user agent to bypass the limitation of Google, as a quick fix (a proper solution will be to use the SSO SDK) + views.loginWebWebView.settings.userAgentString = "Mozilla/5.0 Google" + + // AppRTC requires third party cookies to work + val cookieManager = android.webkit.CookieManager.getInstance() + + // clear the cookies + if (cookieManager == null) { + launchWebView(state) + } else { + if (!cookieManager.hasCookies()) { + launchWebView(state) + } else { + try { + cookieManager.removeAllCookies { launchWebView(state) } + } catch (e: Exception) { + Timber.e(e, " cookieManager.removeAllCookie() fails") + launchWebView(state) + } + } + } + } + + private fun launchWebView(state: LoginViewState2) { + val url = loginViewModel.getFallbackUrl(state.signMode == SignMode2.SignIn, state.deviceId) ?: return + + views.loginWebWebView.loadUrl(url) + + views.loginWebWebView.webViewClient = object : WebViewClient() { + override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, + error: SslError) { + AlertDialog.Builder(requireActivity()) + .setMessage(R.string.ssl_could_not_verify) + .setPositiveButton(R.string.ssl_trust) { _, _ -> handler.proceed() } + .setNegativeButton(R.string.ssl_do_not_trust) { _, _ -> handler.cancel() } + .setOnKeyListener(DialogInterface.OnKeyListener { dialog, keyCode, event -> + if (event.action == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) { + handler.cancel() + dialog.dismiss() + return@OnKeyListener true + } + false + }) + .setCancelable(false) + .show() + } + + override fun onReceivedError(view: WebView, errorCode: Int, description: String, failingUrl: String) { + super.onReceivedError(view, errorCode, description, failingUrl) + + loginViewModel.handle(LoginAction2.PostViewEvent(LoginViewEvents2.OnWebLoginError(errorCode, description, failingUrl))) + } + + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + + views.loginWebToolbar.subtitle = url + } + + override fun onPageFinished(view: WebView, url: String) { + // avoid infinite onPageFinished call + if (url.startsWith("http")) { + // Generic method to make a bridge between JS and the UIWebView + assetReader.readAssetFile("sendObject.js")?.let { view.loadUrl(it) } + + if (state.signMode == SignMode2.SignIn) { + // The function the fallback page calls when the login is complete + assetReader.readAssetFile("onLogin.js")?.let { view.loadUrl(it) } + } else { + // MODE_REGISTER + // The function the fallback page calls when the registration is complete + assetReader.readAssetFile("onRegistered.js")?.let { view.loadUrl(it) } + } + } + } + + /** + * Example of (formatted) url for MODE_LOGIN: + * + *
+             * js:{
+             *     "action":"onLogin",
+             *     "credentials":{
+             *         "user_id":"@user:matrix.org",
+             *         "access_token":"[ACCESS_TOKEN]",
+             *         "home_server":"matrix.org",
+             *         "device_id":"[DEVICE_ID]",
+             *         "well_known":{
+             *             "m.homeserver":{
+             *                 "base_url":"https://matrix.org/"
+             *                 }
+             *             }
+             *         }
+             *    }
+             * 
+ * @param view + * @param url + * @return + */ + override fun shouldOverrideUrlLoading(view: WebView, url: String?): Boolean { + if (url == null) return super.shouldOverrideUrlLoading(view, url as String?) + + if (url.startsWith("js:")) { + var json = url.substring(3) + var javascriptResponse: JavascriptResponse? = null + + try { + // URL decode + json = URLDecoder.decode(json, "UTF-8") + val adapter = MoshiProvider.providesMoshi().adapter(JavascriptResponse::class.java) + javascriptResponse = adapter.fromJson(json) + } catch (e: Exception) { + Timber.e(e, "## shouldOverrideUrlLoading() : fromJson failed") + } + + // succeeds to parse parameters + if (javascriptResponse != null) { + val action = javascriptResponse.action + + if (state.signMode == SignMode2.SignIn) { + if (action == "onLogin") { + javascriptResponse.credentials?.let { notifyViewModel(it) } + } + } else { + // MODE_REGISTER + // check the required parameters + if (action == "onRegistered") { + javascriptResponse.credentials?.let { notifyViewModel(it) } + } + } + } + return true + } + + return super.shouldOverrideUrlLoading(view, url) + } + } + } + + private fun notifyViewModel(credentials: Credentials) { + if (isForSessionRecovery) { + val softLogoutViewModel: SoftLogoutViewModel by activityViewModel() + softLogoutViewModel.handle(SoftLogoutAction.WebLoginSuccess(credentials)) + } else { + loginViewModel.handle(LoginAction2.WebLoginSuccess(credentials)) + } + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction2.ResetLogin) + } + + override fun onBackPressed(toolbarButton: Boolean): Boolean { + return when { + toolbarButton -> super.onBackPressed(toolbarButton) + views.loginWebWebView.canGoBack() -> views.loginWebWebView.goBack().run { true } + else -> super.onBackPressed(toolbarButton) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/login2/SignMode2.kt b/vector/src/main/java/im/vector/app/features/login2/SignMode2.kt new file mode 100644 index 0000000000..f3d59837e7 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/SignMode2.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2019 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.login2 + +enum class SignMode2 { + Unknown, + + // Account creation + SignUp, + + // Login + SignIn +} diff --git a/vector/src/main/java/im/vector/app/features/login2/terms/LoginTermsFragment2.kt b/vector/src/main/java/im/vector/app/features/login2/terms/LoginTermsFragment2.kt new file mode 100755 index 0000000000..ac174f4a48 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/login2/terms/LoginTermsFragment2.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2018 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.login2.terms + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.airbnb.mvrx.args +import im.vector.app.core.extensions.cleanup +import im.vector.app.core.extensions.configureWith +import im.vector.app.core.extensions.toReducedUrl +import im.vector.app.core.utils.openUrlInChromeCustomTab +import im.vector.app.databinding.FragmentLoginTermsBinding +import im.vector.app.features.login.terms.LocalizedFlowDataLoginTermsChecked +import im.vector.app.features.login.terms.LoginTermsFragmentArgument +import im.vector.app.features.login.terms.LoginTermsViewState +import im.vector.app.features.login.terms.PolicyController +import im.vector.app.features.login2.AbstractLoginFragment2 +import im.vector.app.features.login2.LoginAction2 +import im.vector.app.features.login2.LoginViewState2 +import org.matrix.android.sdk.internal.auth.registration.LocalizedFlowDataLoginTerms +import javax.inject.Inject + +/** + * LoginTermsFragment displays the list of policies the user has to accept + */ +class LoginTermsFragment2 @Inject constructor( + private val policyController: PolicyController +) : AbstractLoginFragment2(), + PolicyController.PolicyControllerListener { + + private val params: LoginTermsFragmentArgument by args() + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginTermsBinding { + return FragmentLoginTermsBinding.inflate(inflater, container, false) + } + + private var loginTermsViewState: LoginTermsViewState = LoginTermsViewState(emptyList()) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupViews() + views.loginTermsPolicyList.configureWith(policyController) + policyController.listener = this + + val list = ArrayList() + + params.localizedFlowDataLoginTerms + .forEach { + list.add(LocalizedFlowDataLoginTermsChecked(it)) + } + + loginTermsViewState = LoginTermsViewState(list) + } + + private fun setupViews() { + views.loginTermsSubmit.setOnClickListener { submit() } + } + + override fun onDestroyView() { + views.loginTermsPolicyList.cleanup() + policyController.listener = null + super.onDestroyView() + } + + private fun renderState() { + policyController.setData(loginTermsViewState.localizedFlowDataLoginTermsChecked) + + // Button is enabled only if all checkboxes are checked + views.loginTermsSubmit.isEnabled = loginTermsViewState.allChecked() + } + + override fun setChecked(localizedFlowDataLoginTerms: LocalizedFlowDataLoginTerms, isChecked: Boolean) { + if (isChecked) { + loginTermsViewState.check(localizedFlowDataLoginTerms) + } else { + loginTermsViewState.uncheck(localizedFlowDataLoginTerms) + } + + renderState() + } + + override fun openPolicy(localizedFlowDataLoginTerms: LocalizedFlowDataLoginTerms) { + localizedFlowDataLoginTerms.localizedUrl + ?.takeIf { it.isNotBlank() } + ?.let { + openUrlInChromeCustomTab(requireContext(), null, it) + } + } + + private fun submit() { + loginViewModel.handle(LoginAction2.AcceptTerms) + } + + override fun updateWithState(state: LoginViewState2) { + policyController.homeServer = state.homeServerUrlFromUser.toReducedUrl() + renderState() + } + + override fun resetViewModel() { + loginViewModel.handle(LoginAction2.ResetLogin) + } +} diff --git a/vector/src/main/res/layout/fragment_login_2_signin_password.xml b/vector/src/main/res/layout/fragment_login_2_signin_password.xml new file mode 100644 index 0000000000..638314cf3f --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_2_signin_password.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_2_signin_to.xml b/vector/src/main/res/layout/fragment_login_2_signin_to.xml new file mode 100644 index 0000000000..7f6158530f --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_2_signin_to.xml @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_2_signin_username.xml b/vector/src/main/res/layout/fragment_login_2_signin_username.xml new file mode 100644 index 0000000000..5521b52aab --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_2_signin_username.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_2_signup_password.xml b/vector/src/main/res/layout/fragment_login_2_signup_password.xml new file mode 100644 index 0000000000..19ccc2ff9a --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_2_signup_password.xml @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_2_signup_username.xml b/vector/src/main/res/layout/fragment_login_2_signup_username.xml new file mode 100644 index 0000000000..8c9a7741d1 --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_2_signup_username.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_reset_password_2.xml b/vector/src/main/res/layout/fragment_login_reset_password_2.xml new file mode 100644 index 0000000000..5775c8044f --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_reset_password_2.xml @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_reset_password_success_2.xml b/vector/src/main/res/layout/fragment_login_reset_password_success_2.xml new file mode 100644 index 0000000000..054ed7795a --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_reset_password_success_2.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_server_selection_2.xml b/vector/src/main/res/layout/fragment_login_server_selection_2.xml new file mode 100644 index 0000000000..b00fb13e96 --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_server_selection_2.xml @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_server_url_form_2.xml b/vector/src/main/res/layout/fragment_login_server_url_form_2.xml new file mode 100644 index 0000000000..6774adf76f --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_server_url_form_2.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_splash.xml b/vector/src/main/res/layout/fragment_login_splash.xml index 92655c87b6..9a39c40a68 100644 --- a/vector/src/main/res/layout/fragment_login_splash.xml +++ b/vector/src/main/res/layout/fragment_login_splash.xml @@ -204,4 +204,13 @@ tools:text="@string/settings_version" tools:visibility="visible" /> + + diff --git a/vector/src/main/res/layout/fragment_login_splash_2.xml b/vector/src/main/res/layout/fragment_login_splash_2.xml new file mode 100644 index 0000000000..0b06d1cc65 --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_splash_2.xml @@ -0,0 +1,224 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_sso_only_2.xml b/vector/src/main/res/layout/fragment_login_sso_only_2.xml new file mode 100644 index 0000000000..b302e04586 --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_sso_only_2.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_login_wait_for_email_2.xml b/vector/src/main/res/layout/fragment_login_wait_for_email_2.xml new file mode 100644 index 0000000000..06fac32b8e --- /dev/null +++ b/vector/src/main/res/layout/fragment_login_wait_for_email_2.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/item_login_password_form.xml b/vector/src/main/res/layout/item_login_password_form.xml index d6b5d6898e..d8a2d96809 100644 --- a/vector/src/main/res/layout/item_login_password_form.xml +++ b/vector/src/main/res/layout/item_login_password_form.xml @@ -58,6 +58,8 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="start" + android:paddingStart="0dp" + android:paddingEnd="0dp" android:text="@string/auth_forgot_password" /> + + + + + Type it again + Welcome back %s! + Please enter your password + Please enter your Matrix identifier + Matrix identifiers start with @, for instance @alice:server.org + If you do not know your Matrix identifier, or if your account has been created using Single Sign On (for instance using a Google account), or if you want to connect using your simple name, or an email associated to your account, you have to select your server first. + Choose a server + Please choose a password + Your Matrix identifier + Press back to change + Choose a password + Enter an email associated to your Matrix account + Choose a new password + Please choose an identifier + Your identifier will be used to connect to your Matrix account + Once your account is created, your identifier cannot be modified. However you will be able to change your display name. + If you\'re not sure, select this option + Element Matrix Server and others + Try the new flow + Create a new account + I already have an account + + We just sent an email to %1$s. + Click on the link it contains to continue the account creation. + + \ No newline at end of file