diff --git a/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt b/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt index ff84a46dab..217abd495a 100644 --- a/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt @@ -94,6 +94,18 @@ import im.vector.app.features.login2.created.AccountCreatedFragment import im.vector.app.features.login2.terms.LoginTermsFragment2 import im.vector.app.features.matrixto.MatrixToRoomSpaceFragment import im.vector.app.features.matrixto.MatrixToUserFragment +import im.vector.app.features.onboarding.ftueauth.FtueAuthCaptchaFragment +import im.vector.app.features.onboarding.ftueauth.FtueAuthGenericTextInputFormFragment +import im.vector.app.features.onboarding.ftueauth.FtueAuthLoginFragment +import im.vector.app.features.onboarding.ftueauth.FtueAuthResetPasswordFragment +import im.vector.app.features.onboarding.ftueauth.FtueAuthResetPasswordMailConfirmationFragment +import im.vector.app.features.onboarding.ftueauth.FtueAuthResetPasswordSuccessFragment +import im.vector.app.features.onboarding.ftueauth.FtueAuthServerSelectionFragment +import im.vector.app.features.onboarding.ftueauth.FtueAuthSignUpSignInSelectionFragment +import im.vector.app.features.onboarding.ftueauth.FtueAuthSplashFragment +import im.vector.app.features.onboarding.ftueauth.FtueAuthWaitForEmailFragment +import im.vector.app.features.onboarding.ftueauth.FtueAuthWebFragment +import im.vector.app.features.onboarding.ftueauth.terms.FtueAuthTermsFragment import im.vector.app.features.pin.PinFragment import im.vector.app.features.poll.create.CreatePollFragment import im.vector.app.features.qrcode.QrCodeScannerFragment @@ -386,6 +398,66 @@ interface FragmentModule { @FragmentKey(LoginWaitForEmailFragment2::class) fun bindLoginWaitForEmailFragment2(fragment: LoginWaitForEmailFragment2): Fragment + @Binds + @IntoMap + @FragmentKey(FtueAuthCaptchaFragment::class) + fun bindFtueAuthCaptchaFragment(fragment: FtueAuthCaptchaFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(FtueAuthGenericTextInputFormFragment::class) + fun bindFtueAuthGenericTextInputFormFragment(fragment: FtueAuthGenericTextInputFormFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(FtueAuthLoginFragment::class) + fun bindFtueAuthLoginFragment(fragment: FtueAuthLoginFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(FtueAuthResetPasswordFragment::class) + fun bindFtueAuthResetPasswordFragment(fragment: FtueAuthResetPasswordFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(FtueAuthResetPasswordMailConfirmationFragment::class) + fun bindFtueAuthResetPasswordMailConfirmationFragment(fragment: FtueAuthResetPasswordMailConfirmationFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(FtueAuthResetPasswordSuccessFragment::class) + fun bindFtueAuthResetPasswordSuccessFragment(fragment: FtueAuthResetPasswordSuccessFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(FtueAuthServerSelectionFragment::class) + fun bindFtueAuthServerSelectionFragment(fragment: FtueAuthServerSelectionFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(FtueAuthSignUpSignInSelectionFragment::class) + fun bindFtueAuthSignUpSignInSelectionFragment(fragment: FtueAuthSignUpSignInSelectionFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(FtueAuthSplashFragment::class) + fun bindFtueAuthSplashFragment(fragment: FtueAuthSplashFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(FtueAuthWaitForEmailFragment::class) + fun bindFtueAuthWaitForEmailFragment(fragment: FtueAuthWaitForEmailFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(FtueAuthWebFragment::class) + fun bindFtueAuthWebFragment(fragment: FtueAuthWebFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(FtueAuthTermsFragment::class) + fun bindFtueAuthTermsFragment(fragment: FtueAuthTermsFragment): Fragment + @Binds @IntoMap @FragmentKey(UserListFragment::class) diff --git a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt index 168c5a3819..cc31a7dca6 100644 --- a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt @@ -457,7 +457,7 @@ interface MavericksViewModelModule { @Binds @IntoMap @MavericksViewModelKey(OnboardingViewModel::class) - fun ftueViewModelFactory(factory: OnboardingViewModel.Factory): MavericksAssistedViewModelFactory<*, *> + fun onboardingViewModelFactory(factory: OnboardingViewModel.Factory): MavericksAssistedViewModelFactory<*, *> @Binds @IntoMap diff --git a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingAuthVariant.kt b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingAuthVariant.kt index 8e26a17138..49ceedc381 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingAuthVariant.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingAuthVariant.kt @@ -35,30 +35,30 @@ import im.vector.app.core.extensions.exhaustive 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.LoginCaptchaFragment -import im.vector.app.features.login.LoginCaptchaFragmentArgument import im.vector.app.features.login.LoginConfig -import im.vector.app.features.login.LoginFragment -import im.vector.app.features.login.LoginGenericTextInputFormFragment -import im.vector.app.features.login.LoginGenericTextInputFormFragmentArgument import im.vector.app.features.login.LoginMode -import im.vector.app.features.login.LoginResetPasswordFragment -import im.vector.app.features.login.LoginResetPasswordMailConfirmationFragment -import im.vector.app.features.login.LoginResetPasswordSuccessFragment -import im.vector.app.features.login.LoginServerSelectionFragment -import im.vector.app.features.login.LoginServerUrlFormFragment -import im.vector.app.features.login.LoginSignUpSignInSelectionFragment -import im.vector.app.features.login.LoginSplashFragment -import im.vector.app.features.login.LoginWaitForEmailFragment -import im.vector.app.features.login.LoginWaitForEmailFragmentArgument -import im.vector.app.features.login.LoginWebFragment import im.vector.app.features.login.ServerType import im.vector.app.features.login.SignMode import im.vector.app.features.login.TextInputFormFragmentMode import im.vector.app.features.login.isSupported -import im.vector.app.features.login.terms.LoginTermsFragment -import im.vector.app.features.login.terms.LoginTermsFragmentArgument import im.vector.app.features.login.terms.toLocalizedLoginTerms +import im.vector.app.features.onboarding.ftueauth.FtueAuthCaptchaFragment +import im.vector.app.features.onboarding.ftueauth.FtueAuthCaptchaFragmentArgument +import im.vector.app.features.onboarding.ftueauth.FtueAuthGenericTextInputFormFragment +import im.vector.app.features.onboarding.ftueauth.FtueAuthGenericTextInputFormFragmentArgument +import im.vector.app.features.onboarding.ftueauth.FtueAuthLoginFragment +import im.vector.app.features.onboarding.ftueauth.FtueAuthResetPasswordFragment +import im.vector.app.features.onboarding.ftueauth.FtueAuthResetPasswordMailConfirmationFragment +import im.vector.app.features.onboarding.ftueauth.FtueAuthResetPasswordSuccessFragment +import im.vector.app.features.onboarding.ftueauth.FtueAuthServerSelectionFragment +import im.vector.app.features.onboarding.ftueauth.FtueAuthServerUrlFormFragment +import im.vector.app.features.onboarding.ftueauth.FtueAuthSignUpSignInSelectionFragment +import im.vector.app.features.onboarding.ftueauth.FtueAuthSplashFragment +import im.vector.app.features.onboarding.ftueauth.FtueAuthWaitForEmailFragment +import im.vector.app.features.onboarding.ftueauth.FtueAuthWaitForEmailFragmentArgument +import im.vector.app.features.onboarding.ftueauth.FtueAuthWebFragment +import im.vector.app.features.onboarding.ftueauth.terms.FtueAuthTermsFragment +import im.vector.app.features.onboarding.ftueauth.terms.FtueAuthTermsFragmentArgument 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 @@ -102,7 +102,7 @@ class OnboardingAuthVariant( onboardingViewModel.onEach { updateWithState(it) } - onboardingViewModel.observeViewEvents { handleLoginViewEvents(it) } + onboardingViewModel.observeViewEvents { handleOnboardingViewEvents(it) } } // Get config extra @@ -117,10 +117,10 @@ class OnboardingAuthVariant( } private fun addFirstFragment() { - activity.addFragment(views.loginFragmentContainer, LoginSplashFragment::class.java) + activity.addFragment(views.loginFragmentContainer, FtueAuthSplashFragment::class.java) } - private fun handleLoginViewEvents(onboardingViewEvents: OnboardingViewEvents) { + private fun handleOnboardingViewEvents(onboardingViewEvents: OnboardingViewEvents) { when (onboardingViewEvents) { is OnboardingViewEvents.RegistrationFlowResult -> { // Check that all flows are supported by the application @@ -136,7 +136,7 @@ class OnboardingAuthVariant( // 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 activity.addFragmentToBackstack(views.loginFragmentContainer, - LoginFragment::class.java, + FtueAuthLoginFragment::class.java, tag = FRAGMENT_REGISTRATION_STAGE_TAG, option = commonOption ) @@ -153,7 +153,7 @@ class OnboardingAuthVariant( } is OnboardingViewEvents.OpenServerSelection -> activity.addFragmentToBackstack(views.loginFragmentContainer, - LoginServerSelectionFragment::class.java, + FtueAuthServerSelectionFragment::class.java, option = { ft -> activity.findViewById(R.id.loginSplashLogo)?.let { ft.addSharedElement(it, ViewCompat.getTransitionName(it) ?: "") } // Disable transition of text @@ -167,23 +167,23 @@ class OnboardingAuthVariant( is OnboardingViewEvents.OnSignModeSelected -> onSignModeSelected(onboardingViewEvents) is OnboardingViewEvents.OnLoginFlowRetrieved -> activity.addFragmentToBackstack(views.loginFragmentContainer, - LoginSignUpSignInSelectionFragment::class.java, + FtueAuthSignUpSignInSelectionFragment::class.java, option = commonOption) is OnboardingViewEvents.OnWebLoginError -> onWebLoginError(onboardingViewEvents) is OnboardingViewEvents.OnForgetPasswordClicked -> activity.addFragmentToBackstack(views.loginFragmentContainer, - LoginResetPasswordFragment::class.java, + FtueAuthResetPasswordFragment::class.java, option = commonOption) is OnboardingViewEvents.OnResetPasswordSendThreePidDone -> { supportFragmentManager.popBackStack(FRAGMENT_LOGIN_TAG, POP_BACK_STACK_EXCLUSIVE) activity.addFragmentToBackstack(views.loginFragmentContainer, - LoginResetPasswordMailConfirmationFragment::class.java, + FtueAuthResetPasswordMailConfirmationFragment::class.java, option = commonOption) } is OnboardingViewEvents.OnResetPasswordMailConfirmationSuccess -> { supportFragmentManager.popBackStack(FRAGMENT_LOGIN_TAG, POP_BACK_STACK_EXCLUSIVE) activity.addFragmentToBackstack(views.loginFragmentContainer, - LoginResetPasswordSuccessFragment::class.java, + FtueAuthResetPasswordSuccessFragment::class.java, option = commonOption) } is OnboardingViewEvents.OnResetPasswordMailConfirmationSuccessDone -> { @@ -194,8 +194,8 @@ class OnboardingAuthVariant( // Pop the enter email Fragment supportFragmentManager.popBackStack(FRAGMENT_REGISTRATION_STAGE_TAG, FragmentManager.POP_BACK_STACK_INCLUSIVE) activity.addFragmentToBackstack(views.loginFragmentContainer, - LoginWaitForEmailFragment::class.java, - LoginWaitForEmailFragmentArgument(onboardingViewEvents.email), + FtueAuthWaitForEmailFragment::class.java, + FtueAuthWaitForEmailFragmentArgument(onboardingViewEvents.email), tag = FRAGMENT_REGISTRATION_STAGE_TAG, option = commonOption) } @@ -203,8 +203,8 @@ class OnboardingAuthVariant( // Pop the enter Msisdn Fragment supportFragmentManager.popBackStack(FRAGMENT_REGISTRATION_STAGE_TAG, FragmentManager.POP_BACK_STACK_INCLUSIVE) activity.addFragmentToBackstack(views.loginFragmentContainer, - LoginGenericTextInputFormFragment::class.java, - LoginGenericTextInputFormFragmentArgument(TextInputFormFragmentMode.ConfirmMsisdn, true, onboardingViewEvents.msisdn), + FtueAuthGenericTextInputFormFragment::class.java, + FtueAuthGenericTextInputFormFragmentArgument(TextInputFormFragmentMode.ConfirmMsisdn, true, onboardingViewEvents.msisdn), tag = FRAGMENT_REGISTRATION_STAGE_TAG, option = commonOption) } @@ -242,23 +242,23 @@ class OnboardingAuthVariant( .show() } - private fun onServerSelectionDone(loginViewEvents: OnboardingViewEvents.OnServerSelectionDone) { - when (loginViewEvents.serverType) { + private fun onServerSelectionDone(OnboardingViewEvents: OnboardingViewEvents.OnServerSelectionDone) { + when (OnboardingViewEvents.serverType) { ServerType.MatrixOrg -> Unit // In this case, we wait for the login flow ServerType.EMS, ServerType.Other -> activity.addFragmentToBackstack(views.loginFragmentContainer, - LoginServerUrlFormFragment::class.java, + FtueAuthServerUrlFormFragment::class.java, option = commonOption) ServerType.Unknown -> Unit /* Should not happen */ } } - private fun onSignModeSelected(loginViewEvents: OnboardingViewEvents.OnSignModeSelected) = withState(onboardingViewModel) { state -> + private fun onSignModeSelected(OnboardingViewEvents: OnboardingViewEvents.OnSignModeSelected) = withState(onboardingViewModel) { state -> // state.signMode could not be ready yet. So use value from the ViewEvent - when (loginViewEvents.signMode) { + when (OnboardingViewEvents.signMode) { SignMode.Unknown -> error("Sign mode has to be set before calling this method") SignMode.SignUp -> { - // This is managed by the LoginViewEvents + // This is managed by the OnboardingViewEvents } SignMode.SignIn -> { // It depends on the LoginMode @@ -267,14 +267,14 @@ class OnboardingAuthVariant( is LoginMode.Sso -> error("Developer error") is LoginMode.SsoAndPassword, LoginMode.Password -> activity.addFragmentToBackstack(views.loginFragmentContainer, - LoginFragment::class.java, + FtueAuthLoginFragment::class.java, tag = FRAGMENT_LOGIN_TAG, option = commonOption) LoginMode.Unsupported -> onLoginModeNotSupported(state.loginModeSupportedTypes) }.exhaustive } SignMode.SignInWithMatrixId -> activity.addFragmentToBackstack(views.loginFragmentContainer, - LoginFragment::class.java, + FtueAuthLoginFragment::class.java, tag = FRAGMENT_LOGIN_TAG, option = commonOption) }.exhaustive @@ -295,7 +295,7 @@ class OnboardingAuthVariant( .setMessage(activity.getString(R.string.login_registration_not_supported)) .setPositiveButton(R.string.yes) { _, _ -> activity.addFragmentToBackstack(views.loginFragmentContainer, - LoginWebFragment::class.java, + FtueAuthWebFragment::class.java, option = commonOption) } .setNegativeButton(R.string.no, null) @@ -308,7 +308,7 @@ class OnboardingAuthVariant( .setMessage(activity.getString(R.string.login_mode_not_supported, supportedTypes.joinToString { "'$it'" })) .setPositiveButton(R.string.yes) { _, _ -> activity.addFragmentToBackstack(views.loginFragmentContainer, - LoginWebFragment::class.java, + FtueAuthWebFragment::class.java, option = commonOption) } .setNegativeButton(R.string.no, null) @@ -338,23 +338,23 @@ class OnboardingAuthVariant( when (stage) { is Stage.ReCaptcha -> activity.addFragmentToBackstack(views.loginFragmentContainer, - LoginCaptchaFragment::class.java, - LoginCaptchaFragmentArgument(stage.publicKey), + FtueAuthCaptchaFragment::class.java, + FtueAuthCaptchaFragmentArgument(stage.publicKey), tag = FRAGMENT_REGISTRATION_STAGE_TAG, option = commonOption) is Stage.Email -> activity.addFragmentToBackstack(views.loginFragmentContainer, - LoginGenericTextInputFormFragment::class.java, - LoginGenericTextInputFormFragmentArgument(TextInputFormFragmentMode.SetEmail, stage.mandatory), + FtueAuthGenericTextInputFormFragment::class.java, + FtueAuthGenericTextInputFormFragmentArgument(TextInputFormFragmentMode.SetEmail, stage.mandatory), tag = FRAGMENT_REGISTRATION_STAGE_TAG, option = commonOption) is Stage.Msisdn -> activity.addFragmentToBackstack(views.loginFragmentContainer, - LoginGenericTextInputFormFragment::class.java, - LoginGenericTextInputFormFragmentArgument(TextInputFormFragmentMode.SetMsisdn, stage.mandatory), + FtueAuthGenericTextInputFormFragment::class.java, + FtueAuthGenericTextInputFormFragmentArgument(TextInputFormFragmentMode.SetMsisdn, stage.mandatory), tag = FRAGMENT_REGISTRATION_STAGE_TAG, option = commonOption) is Stage.Terms -> activity.addFragmentToBackstack(views.loginFragmentContainer, - LoginTermsFragment::class.java, - LoginTermsFragmentArgument(stage.policies.toLocalizedLoginTerms(activity.getString(R.string.resources_language))), + FtueAuthTermsFragment::class.java, + FtueAuthTermsFragmentArgument(stage.policies.toLocalizedLoginTerms(activity.getString(R.string.resources_language))), tag = FRAGMENT_REGISTRATION_STAGE_TAG, option = commonOption) else -> Unit // Should not happen diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/AbstractFtueAuthFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/AbstractFtueAuthFragment.kt new file mode 100644 index 0000000000..0caf2ea152 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/AbstractFtueAuthFragment.kt @@ -0,0 +1,183 @@ +/* + * 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.onboarding.ftueauth + +import android.os.Bundle +import android.view.View +import androidx.annotation.CallSuper +import androidx.transition.TransitionInflater +import androidx.viewbinding.ViewBinding +import com.airbnb.mvrx.activityViewModel +import com.airbnb.mvrx.withState +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import im.vector.app.R +import im.vector.app.core.dialogs.UnrecognizedCertificateDialog +import im.vector.app.core.extensions.exhaustive +import im.vector.app.core.platform.OnBackPressed +import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.features.onboarding.OnboardingAction +import im.vector.app.features.onboarding.OnboardingViewEvents +import im.vector.app.features.onboarding.OnboardingViewModel +import im.vector.app.features.onboarding.OnboardingViewState +import kotlinx.coroutines.CancellationException +import org.matrix.android.sdk.api.failure.Failure +import org.matrix.android.sdk.api.failure.MatrixError +import javax.net.ssl.HttpsURLConnection + +/** + * Parent Fragment for all the login/registration screens + */ +abstract class AbstractFtueAuthFragment : VectorBaseFragment(), OnBackPressed { + + protected val viewModel: OnboardingViewModel 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) + + context?.let { + sharedElementEnterTransition = TransitionInflater.from(it).inflateTransition(android.R.transition.move) + } + } + + @CallSuper + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel.observeViewEvents { + handleOnboardingViewEvents(it) + } + } + + private fun handleOnboardingViewEvents(viewEvents: OnboardingViewEvents) { + when (viewEvents) { + is OnboardingViewEvents.Failure -> showFailure(viewEvents.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 CancellationException -> + /* Ignore this error, user has cancelled the action */ + Unit + is Failure.ServerError -> + if (throwable.error.code == MatrixError.M_FORBIDDEN && + throwable.httpCode == HttpsURLConnection.HTTP_FORBIDDEN /* 403 */) { + MaterialAlertDialogBuilder(requireActivity()) + .setTitle(R.string.dialog_title_error) + .setMessage(getString(R.string.login_registration_disabled)) + .setPositiveButton(R.string.ok, null) + .show() + } else { + onError(throwable) + } + 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 + viewModel.handle(OnboardingAction.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 && viewModel.isRegistrationStarted -> { + // Ask for confirmation before cancelling the registration + MaterialAlertDialogBuilder(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 + MaterialAlertDialogBuilder(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(viewModel) { state -> + // True when email is sent with success to the homeserver + isResetPasswordStarted = state.resetPasswordEmail.isNullOrBlank().not() + + updateWithState(state) + } + + open fun updateWithState(state: OnboardingViewState) { + // No op by default + } + + // Reset any modification on the viewModel by the current fragment + abstract fun resetViewModel() +} diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/AbstractSSOFtueAuthFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/AbstractSSOFtueAuthFragment.kt new file mode 100644 index 0000000000..2e9925516c --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/AbstractSSOFtueAuthFragment.kt @@ -0,0 +1,102 @@ +/* + * 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.onboarding.ftueauth + +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.SSORedirectRouterActivity +import im.vector.app.features.login.hasSso +import im.vector.app.features.login.ssoIdentityProviders + +abstract class AbstractSSOFtueAuthFragment : AbstractFtueAuthFragment() { + + // 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(viewModel) { 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(viewModel) { 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) + } + + fun openInCustomTab(ssoUrl: String) { + openUrlInChromeCustomTab(requireContext(), customTabsSession, ssoUrl) + } + + private fun prefetchIfNeeded() { + withState(viewModel) { state -> + if (state.loginMode.hasSso() && state.loginMode.ssoIdentityProviders().isNullOrEmpty()) { + // in this case we can prefetch (not other cases for privacy concerns) + viewModel.getSsoUrl( + redirectUrl = SSORedirectRouterActivity.VECTOR_REDIRECT_URL, + deviceId = state.deviceId, + providerId = null + ) + ?.let { prefetchUrl(it) } + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCaptchaFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCaptchaFragment.kt new file mode 100644 index 0000000000..e2e390ae2d --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthCaptchaFragment.kt @@ -0,0 +1,202 @@ +/* + * 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.onboarding.ftueauth + +import android.annotation.SuppressLint +import android.content.DialogInterface +import android.graphics.Bitmap +import android.net.http.SslError +import android.os.Build +import android.os.Parcelable +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.core.view.isVisible +import com.airbnb.mvrx.args +import com.google.android.material.dialog.MaterialAlertDialogBuilder +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.onboarding.OnboardingAction +import im.vector.app.features.onboarding.OnboardingViewState +import kotlinx.parcelize.Parcelize +import org.matrix.android.sdk.internal.di.MoshiProvider +import timber.log.Timber +import java.net.URLDecoder +import java.util.Formatter +import javax.inject.Inject + +@Parcelize +data class FtueAuthCaptchaFragmentArgument( + val siteKey: String +) : Parcelable + +/** + * In this screen, the user is asked to confirm he is not a robot + */ +class FtueAuthCaptchaFragment @Inject constructor( + private val assetReader: AssetReader +) : AbstractFtueAuthFragment() { + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginCaptchaBinding { + return FragmentLoginCaptchaBinding.inflate(inflater, container, false) + } + + private val params: FtueAuthCaptchaFragmentArgument by args() + + private var isWebViewLoaded = false + + @SuppressLint("SetJavaScriptEnabled") + private fun setupWebView(state: OnboardingViewState) { + 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 + } + + MaterialAlertDialogBuilder(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) { + viewModel.handle(OnboardingAction.CaptchaDone(response)) + } + } + return true + } + } + } + + override fun resetViewModel() { + viewModel.handle(OnboardingAction.ResetLogin) + } + + override fun updateWithState(state: OnboardingViewState) { + if (!isWebViewLoaded) { + setupWebView(state) + isWebViewLoaded = true + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthGenericTextInputFormFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthGenericTextInputFormFragment.kt new file mode 100644 index 0000000000..bd5054f646 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthGenericTextInputFormFragment.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.onboarding.ftueauth + +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 androidx.lifecycle.lifecycleScope +import com.airbnb.mvrx.args +import com.google.i18n.phonenumbers.NumberParseException +import com.google.i18n.phonenumbers.PhoneNumberUtil +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 im.vector.app.features.login.TextInputFormFragmentMode +import im.vector.app.features.onboarding.OnboardingAction +import im.vector.app.features.onboarding.OnboardingViewEvents +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +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 reactivecircus.flowbinding.android.widget.textChanges +import javax.inject.Inject + +@Parcelize +data class FtueAuthGenericTextInputFormFragmentArgument( + val mode: TextInputFormFragmentMode, + val mandatory: Boolean, + val extra: String = "" +) : Parcelable + +/** + * In this screen, the user is asked for a text input + */ +class FtueAuthGenericTextInputFormFragment @Inject constructor() : AbstractFtueAuthFragment() { + + private val params: FtueAuthGenericTextInputFormFragmentArgument 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() + .onEach { + views.loginGenericTextInputFormTil.error = null + } + .launchIn(viewLifecycleOwner.lifecycleScope) + } + + 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 -> { + viewModel.handle(OnboardingAction.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 + viewModel.handle(OnboardingAction.RegisterDummy) + } else { + when (params.mode) { + TextInputFormFragmentMode.SetEmail -> { + viewModel.handle(OnboardingAction.AddThreePid(RegisterThreePid.Email(text))) + } + TextInputFormFragmentMode.SetMsisdn -> { + getCountryCodeOrShowError(text)?.let { countryCode -> + viewModel.handle(OnboardingAction.AddThreePid(RegisterThreePid.Msisdn(text, countryCode))) + } + } + TextInputFormFragmentMode.ConfirmMsisdn -> { + viewModel.handle(OnboardingAction.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() + .onEach { + views.loginGenericTextInputFormSubmit.isEnabled = isInputValid(it) + } + .launchIn(viewLifecycleOwner.lifecycleScope) + } + + 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 + viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnSendEmailSuccess(viewModel.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 + viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnSendMsisdnSuccess(viewModel.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() { + viewModel.handle(OnboardingAction.ResetLogin) + } +} diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthLoginFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthLoginFragment.kt new file mode 100644 index 0000000000..5f15d9a35d --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthLoginFragment.kt @@ -0,0 +1,319 @@ +/* + * 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.onboarding.ftueauth + +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 androidx.lifecycle.lifecycleScope +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.Success +import im.vector.app.R +import im.vector.app.core.extensions.exhaustive +import im.vector.app.core.extensions.hideKeyboard +import im.vector.app.core.extensions.hidePassword +import im.vector.app.core.extensions.toReducedUrl +import im.vector.app.databinding.FragmentLoginBinding +import im.vector.app.features.login.LoginMode +import im.vector.app.features.login.SSORedirectRouterActivity +import im.vector.app.features.login.ServerType +import im.vector.app.features.login.SignMode +import im.vector.app.features.login.SocialLoginButtonsView +import im.vector.app.features.onboarding.OnboardingAction +import im.vector.app.features.onboarding.OnboardingViewEvents +import im.vector.app.features.onboarding.OnboardingViewState +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +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 reactivecircus.flowbinding.android.widget.textChanges +import javax.inject.Inject + +/** + * In this screen: + * In signin mode: + * - the user is asked for login (or email) and password to sign in to a homeserver. + * - He also can reset his password + * In signup mode: + * - the user is asked for login and password + */ +class FtueAuthLoginFragment @Inject constructor() : AbstractSSOFtueAuthFragment() { + + private var isSignupMode = 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?): FragmentLoginBinding { + return FragmentLoginBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupSubmitButton() + setupForgottenPasswordButton() + + 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(state: OnboardingViewState) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + when (state.signMode) { + SignMode.Unknown -> error("developer error") + SignMode.SignUp -> { + views.loginField.setAutofillHints(HintConstants.AUTOFILL_HINT_NEW_USERNAME) + views.passwordField.setAutofillHints(HintConstants.AUTOFILL_HINT_NEW_PASSWORD) + } + SignMode.SignIn, + SignMode.SignInWithMatrixId -> { + views.loginField.setAutofillHints(HintConstants.AUTOFILL_HINT_USERNAME) + views.passwordField.setAutofillHints(HintConstants.AUTOFILL_HINT_PASSWORD) + } + }.exhaustive + } + } + + private fun setupSocialLoginButtons(state: OnboardingViewState) { + views.loginSocialLoginButtons.mode = when (state.signMode) { + SignMode.Unknown -> error("developer error") + SignMode.SignUp -> SocialLoginButtonsView.Mode.MODE_SIGN_UP + SignMode.SignIn, + SignMode.SignInWithMatrixId -> SocialLoginButtonsView.Mode.MODE_SIGN_IN + }.exhaustive + } + + 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(if (isSignupMode) { + R.string.error_empty_field_choose_user_name + } else { + R.string.error_empty_field_enter_user_name + }) + error++ + } + if (isSignupMode && isNumericOnlyUserIdForbidden && login.isDigitsOnly()) { + views.loginFieldTil.error = "The homeserver does not accept username with only digits." + error++ + } + if (password.isEmpty()) { + views.passwordFieldTil.error = getString(if (isSignupMode) { + R.string.error_empty_field_choose_password + } else { + R.string.error_empty_field_your_password + }) + error++ + } + + if (error == 0) { + viewModel.handle(OnboardingAction.LoginOrRegister(login, password, getString(R.string.login_default_session_public_name))) + } + } + + private fun cleanupUi() { + views.loginSubmit.hideKeyboard() + views.loginFieldTil.error = null + views.passwordFieldTil.error = null + } + + private fun setupUi(state: OnboardingViewState) { + views.loginFieldTil.hint = getString(when (state.signMode) { + SignMode.Unknown -> error("developer error") + SignMode.SignUp -> R.string.login_signup_username_hint + SignMode.SignIn -> R.string.login_signin_username_hint + SignMode.SignInWithMatrixId -> R.string.login_signin_matrix_id_hint + }) + + // Handle direct signin first + if (state.signMode == SignMode.SignInWithMatrixId) { + views.loginServerIcon.isVisible = false + views.loginTitle.text = getString(R.string.login_signin_matrix_id_title) + views.loginNotice.text = getString(R.string.login_signin_matrix_id_notice) + views.loginPasswordNotice.isVisible = true + } else { + val resId = when (state.signMode) { + SignMode.Unknown -> error("developer error") + SignMode.SignUp -> R.string.login_signup_to + SignMode.SignIn -> R.string.login_connect_to + SignMode.SignInWithMatrixId -> R.string.login_connect_to + } + + when (state.serverType) { + ServerType.MatrixOrg -> { + views.loginServerIcon.isVisible = true + views.loginServerIcon.setImageResource(R.drawable.ic_logo_matrix_org) + views.loginTitle.text = getString(resId, state.homeServerUrlFromUser.toReducedUrl()) + views.loginNotice.text = getString(R.string.login_server_matrix_org_text) + } + ServerType.EMS -> { + views.loginServerIcon.isVisible = true + views.loginServerIcon.setImageResource(R.drawable.ic_logo_element_matrix_services) + views.loginTitle.text = getString(resId, "Element Matrix Services") + views.loginNotice.text = getString(R.string.login_server_modular_text) + } + ServerType.Other -> { + views.loginServerIcon.isVisible = false + views.loginTitle.text = getString(resId, state.homeServerUrlFromUser.toReducedUrl()) + views.loginNotice.text = getString(R.string.login_server_other_text) + } + ServerType.Unknown -> Unit /* Should not happen */ + } + views.loginPasswordNotice.isVisible = false + + if (state.loginMode is LoginMode.SsoAndPassword) { + views.loginSocialLoginContainer.isVisible = true + views.loginSocialLoginButtons.ssoIdentityProviders = state.loginMode.ssoIdentityProviders?.sorted() + views.loginSocialLoginButtons.listener = object : SocialLoginButtonsView.InteractionListener { + override fun onProviderSelected(id: String?) { + viewModel.getSsoUrl( + redirectUrl = SSORedirectRouterActivity.VECTOR_REDIRECT_URL, + deviceId = state.deviceId, + providerId = id + ) + ?.let { openInCustomTab(it) } + } + } + } else { + views.loginSocialLoginContainer.isVisible = false + views.loginSocialLoginButtons.ssoIdentityProviders = null + } + } + } + + private fun setupButtons(state: OnboardingViewState) { + views.forgetPasswordButton.isVisible = state.signMode == SignMode.SignIn + + views.loginSubmit.text = getString(when (state.signMode) { + SignMode.Unknown -> error("developer error") + SignMode.SignUp -> R.string.login_signup_submit + SignMode.SignIn, + SignMode.SignInWithMatrixId -> R.string.login_signin + }) + } + + private fun setupSubmitButton() { + views.loginSubmit.setOnClickListener { submit() } + combine( + views.loginField.textChanges().map { it.trim().isNotEmpty() }, + views.passwordField.textChanges().map { it.isNotEmpty() } + ) { isLoginNotEmpty, isPasswordNotEmpty -> + isLoginNotEmpty && isPasswordNotEmpty + } + .onEach { + views.loginFieldTil.error = null + views.passwordFieldTil.error = null + views.loginSubmit.isEnabled = it + } + .launchIn(viewLifecycleOwner.lifecycleScope) + } + + private fun forgetPasswordClicked() { + viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnForgetPasswordClicked)) + } + + override fun resetViewModel() { + viewModel.handle(OnboardingAction.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 { + views.loginFieldTil.error = errorFormatter.toHumanReadable(throwable) + } + } + + override fun updateWithState(state: OnboardingViewState) { + isSignupMode = state.signMode == SignMode.SignUp + isNumericOnlyUserIdForbidden = state.serverType == ServerType.MatrixOrg + + setupUi(state) + setupAutoFill(state) + setupSocialLoginButtons(state) + setupButtons(state) + + when (state.asyncLoginAction) { + is Loading -> { + // Ensure password is hidden + views.passwordField.hidePassword() + } + is Fail -> { + val error = state.asyncLoginAction.error + if (error is Failure.ServerError && + error.error.code == MatrixError.M_FORBIDDEN && + error.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 (error.isInvalidPassword() && spaceInPassword()) { + views.passwordFieldTil.error = getString(R.string.auth_invalid_login_param_space_in_password) + } else { + views.passwordFieldTil.error = errorFormatter.toHumanReadable(error) + } + } + } + // Success is handled by the LoginActivity + is Success -> Unit + } + + when (state.asyncRegistration) { + is Loading -> { + // Ensure password is hidden + views.passwordField.hidePassword() + } + // Success is handled by the LoginActivity + is Success -> Unit + } + } + + /** + * 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/onboarding/ftueauth/FtueAuthResetPasswordFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthResetPasswordFragment.kt new file mode 100644 index 0000000000..6a224dfae8 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthResetPasswordFragment.kt @@ -0,0 +1,131 @@ +/* + * 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.onboarding.ftueauth + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.lifecycleScope +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.Success +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import im.vector.app.R +import im.vector.app.core.extensions.hideKeyboard +import im.vector.app.core.extensions.hidePassword +import im.vector.app.core.extensions.isEmail +import im.vector.app.core.extensions.toReducedUrl +import im.vector.app.databinding.FragmentLoginResetPasswordBinding +import im.vector.app.features.onboarding.OnboardingAction +import im.vector.app.features.onboarding.OnboardingViewState +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.android.widget.textChanges +import javax.inject.Inject + +/** + * In this screen, the user is asked for email and new password to reset his password + */ +class FtueAuthResetPasswordFragment @Inject constructor() : AbstractFtueAuthFragment() { + + // Show warning only once + private var showWarning = true + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginResetPasswordBinding { + return FragmentLoginResetPasswordBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupSubmitButton() + } + + private fun setupUi(state: OnboardingViewState) { + views.resetPasswordTitle.text = getString(R.string.login_reset_password_on, state.homeServerUrlFromUser.toReducedUrl()) + } + + private fun setupSubmitButton() { + views.resetPasswordSubmit.setOnClickListener { submit() } + combine( + views.resetPasswordEmail.textChanges().map { it.isEmail() }, + views.passwordField.textChanges().map { it.isNotEmpty() } + ) { isEmail, isPasswordNotEmpty -> + isEmail && isPasswordNotEmpty + } + .onEach { + views.resetPasswordEmailTil.error = null + views.passwordFieldTil.error = null + views.resetPasswordSubmit.isEnabled = it + } + .launchIn(viewLifecycleOwner.lifecycleScope) + } + + private fun submit() { + cleanupUi() + + if (showWarning) { + showWarning = false + // Display a warning as Riot-Web does first + MaterialAlertDialogBuilder(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.action_cancel, null) + .show() + } else { + doSubmit() + } + } + + private fun doSubmit() { + val email = views.resetPasswordEmail.text.toString() + val password = views.passwordField.text.toString() + + viewModel.handle(OnboardingAction.ResetPassword(email, password)) + } + + private fun cleanupUi() { + views.resetPasswordSubmit.hideKeyboard() + views.resetPasswordEmailTil.error = null + views.passwordFieldTil.error = null + } + + override fun resetViewModel() { + viewModel.handle(OnboardingAction.ResetResetPassword) + } + + override fun updateWithState(state: OnboardingViewState) { + setupUi(state) + + when (state.asyncResetPassword) { + is Loading -> { + // Ensure new password is hidden + views.passwordField.hidePassword() + } + is Fail -> { + views.resetPasswordEmailTil.error = errorFormatter.toHumanReadable(state.asyncResetPassword.error) + } + is Success -> Unit + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthResetPasswordMailConfirmationFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthResetPasswordMailConfirmationFragment.kt new file mode 100644 index 0000000000..1d5e1aa00a --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthResetPasswordMailConfirmationFragment.kt @@ -0,0 +1,81 @@ +/* + * 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.onboarding.ftueauth + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Success +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import im.vector.app.R +import im.vector.app.databinding.FragmentLoginResetPasswordMailConfirmationBinding +import im.vector.app.features.onboarding.OnboardingAction +import im.vector.app.features.onboarding.OnboardingViewState +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 FtueAuthResetPasswordMailConfirmationFragment @Inject constructor() : AbstractFtueAuthFragment() { + + 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: OnboardingViewState) { + views.resetPasswordMailConfirmationNotice.text = getString(R.string.login_reset_password_mail_confirmation_notice, state.resetPasswordEmail) + } + + private fun submit() { + viewModel.handle(OnboardingAction.ResetPasswordMailConfirmed) + } + + override fun resetViewModel() { + viewModel.handle(OnboardingAction.ResetResetPassword) + } + + override fun updateWithState(state: OnboardingViewState) { + setupUi(state) + + when (state.asyncResetMailConfirmed) { + is Fail -> { + // Link in email not yet clicked ? + val message = if (state.asyncResetMailConfirmed.error.is401()) { + getString(R.string.auth_reset_password_error_unauthorized) + } else { + errorFormatter.toHumanReadable(state.asyncResetMailConfirmed.error) + } + + MaterialAlertDialogBuilder(requireActivity()) + .setTitle(R.string.dialog_title_error) + .setMessage(message) + .setPositiveButton(R.string.ok, null) + .show() + } + is Success -> Unit + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthResetPasswordSuccessFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthResetPasswordSuccessFragment.kt new file mode 100644 index 0000000000..74b1a7f8c2 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthResetPasswordSuccessFragment.kt @@ -0,0 +1,50 @@ +/* + * 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.onboarding.ftueauth + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import im.vector.app.databinding.FragmentLoginResetPasswordSuccessBinding +import im.vector.app.features.onboarding.OnboardingAction +import im.vector.app.features.onboarding.OnboardingViewEvents +import javax.inject.Inject + +/** + * In this screen, we confirm to the user that his password has been reset + */ +class FtueAuthResetPasswordSuccessFragment @Inject constructor() : AbstractFtueAuthFragment() { + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginResetPasswordSuccessBinding { + return FragmentLoginResetPasswordSuccessBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + views.resetPasswordSuccessSubmit.setOnClickListener { submit() } + } + + private fun submit() { + viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.OnResetPasswordMailConfirmationSuccessDone)) + } + + override fun resetViewModel() { + viewModel.handle(OnboardingAction.ResetResetPassword) + } +} diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthServerSelectionFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthServerSelectionFragment.kt new file mode 100644 index 0000000000..f72bd2c5d3 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthServerSelectionFragment.kt @@ -0,0 +1,96 @@ +/* + * 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.onboarding.ftueauth + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import im.vector.app.R +import im.vector.app.core.utils.openUrlInChromeCustomTab +import im.vector.app.databinding.FragmentLoginServerSelectionBinding +import im.vector.app.features.login.EMS_LINK +import im.vector.app.features.login.ServerType +import im.vector.app.features.login.SignMode +import im.vector.app.features.onboarding.OnboardingAction +import im.vector.app.features.onboarding.OnboardingViewState +import me.gujun.android.span.span +import javax.inject.Inject + +/** + * In this screen, the user will choose between matrix.org, modular or other type of homeserver + */ +class FtueAuthServerSelectionFragment @Inject constructor() : AbstractFtueAuthFragment() { + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginServerSelectionBinding { + return FragmentLoginServerSelectionBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + initViews() + initTextViews() + } + + private fun initViews() { + views.loginServerChoiceEmsLearnMore.setOnClickListener { learnMore() } + views.loginServerChoiceMatrixOrg.setOnClickListener { selectMatrixOrg() } + views.loginServerChoiceEms.setOnClickListener { selectEMS() } + views.loginServerChoiceOther.setOnClickListener { selectOther() } + views.loginServerIKnowMyIdSubmit.setOnClickListener { loginWithMatrixId() } + } + + private fun updateSelectedChoice(state: OnboardingViewState) { + views.loginServerChoiceMatrixOrg.isChecked = state.serverType == ServerType.MatrixOrg + } + + private fun initTextViews() { + views.loginServerChoiceEmsLearnMore.text = span { + text = getString(R.string.login_server_modular_learn_more) + textDecorationLine = "underline" + } + } + + private fun learnMore() { + openUrlInChromeCustomTab(requireActivity(), null, EMS_LINK) + } + + private fun selectMatrixOrg() { + viewModel.handle(OnboardingAction.UpdateServerType(ServerType.MatrixOrg)) + } + + private fun selectEMS() { + viewModel.handle(OnboardingAction.UpdateServerType(ServerType.EMS)) + } + + private fun selectOther() { + viewModel.handle(OnboardingAction.UpdateServerType(ServerType.Other)) + } + + private fun loginWithMatrixId() { + viewModel.handle(OnboardingAction.UpdateSignMode(SignMode.SignInWithMatrixId)) + } + + override fun resetViewModel() { + viewModel.handle(OnboardingAction.ResetHomeServerType) + } + + override fun updateWithState(state: OnboardingViewState) { + updateSelectedChoice(state) + } +} diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthServerUrlFormFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthServerUrlFormFragment.kt new file mode 100644 index 0000000000..2cae9743a7 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthServerUrlFormFragment.kt @@ -0,0 +1,167 @@ +/* + * 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.onboarding.ftueauth + +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 androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import com.google.android.material.textfield.TextInputLayout +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.core.utils.openUrlInChromeCustomTab +import im.vector.app.databinding.FragmentLoginServerUrlFormBinding +import im.vector.app.features.login.EMS_LINK +import im.vector.app.features.login.ServerType +import im.vector.app.features.onboarding.OnboardingAction +import im.vector.app.features.onboarding.OnboardingViewState +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.matrix.android.sdk.api.failure.Failure +import reactivecircus.flowbinding.android.widget.textChanges +import java.net.UnknownHostException +import javax.inject.Inject + +/** + * In this screen, the user is prompted to enter a homeserver url + */ +class FtueAuthServerUrlFormFragment @Inject constructor() : AbstractFtueAuthFragment() { + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginServerUrlFormBinding { + return FragmentLoginServerUrlFormBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupViews() + setupHomeServerField() + } + + private fun setupViews() { + views.loginServerUrlFormLearnMore.setOnClickListener { learnMore() } + views.loginServerUrlFormClearHistory.setOnClickListener { clearHistory() } + views.loginServerUrlFormSubmit.setOnClickListener { submit() } + } + + private fun setupHomeServerField() { + views.loginServerUrlFormHomeServerUrl.textChanges() + .onEach { + views.loginServerUrlFormHomeServerUrlTil.error = null + views.loginServerUrlFormSubmit.isEnabled = it.isNotBlank() + } + .launchIn(viewLifecycleOwner.lifecycleScope) + + views.loginServerUrlFormHomeServerUrl.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + views.loginServerUrlFormHomeServerUrl.dismissDropDown() + submit() + return@setOnEditorActionListener true + } + return@setOnEditorActionListener false + } + } + + private fun setupUi(state: OnboardingViewState) { + when (state.serverType) { + ServerType.EMS -> { + views.loginServerUrlFormIcon.isVisible = true + views.loginServerUrlFormTitle.text = getString(R.string.login_connect_to_modular) + views.loginServerUrlFormText.text = getString(R.string.login_server_url_form_modular_text) + views.loginServerUrlFormLearnMore.isVisible = true + views.loginServerUrlFormHomeServerUrlTil.hint = getText(R.string.login_server_url_form_modular_hint) + views.loginServerUrlFormNotice.text = getString(R.string.login_server_url_form_modular_notice) + } + else -> { + views.loginServerUrlFormIcon.isVisible = false + views.loginServerUrlFormTitle.text = getString(R.string.login_server_other_title) + views.loginServerUrlFormText.text = getString(R.string.login_connect_to_a_custom_server) + views.loginServerUrlFormLearnMore.isVisible = false + views.loginServerUrlFormHomeServerUrlTil.hint = getText(R.string.login_server_url_form_other_hint) + views.loginServerUrlFormNotice.text = getString(R.string.login_server_url_form_common_notice) + } + } + 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 + } + + private fun learnMore() { + openUrlInChromeCustomTab(requireActivity(), null, EMS_LINK) + } + + private fun clearHistory() { + viewModel.handle(OnboardingAction.ClearHomeServerHistory) + } + + override fun resetViewModel() { + viewModel.handle(OnboardingAction.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*/) + viewModel.handle(OnboardingAction.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 { + errorFormatter.toHumanReadable(throwable) + } + } + + override fun updateWithState(state: OnboardingViewState) { + setupUi(state) + + views.loginServerUrlFormClearHistory.isInvisible = state.knownCustomHomeServersUrls.isEmpty() + } +} diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSignUpSignInSelectionFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSignUpSignInSelectionFragment.kt new file mode 100644 index 0000000000..e9ae5022e2 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSignUpSignInSelectionFragment.kt @@ -0,0 +1,142 @@ +/* + * 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.onboarding.ftueauth + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import com.airbnb.mvrx.withState +import im.vector.app.R +import im.vector.app.core.extensions.toReducedUrl +import im.vector.app.databinding.FragmentLoginSignupSigninSelectionBinding +import im.vector.app.features.login.LoginMode +import im.vector.app.features.login.SSORedirectRouterActivity +import im.vector.app.features.login.ServerType +import im.vector.app.features.login.SignMode +import im.vector.app.features.login.SocialLoginButtonsView +import im.vector.app.features.login.ssoIdentityProviders +import im.vector.app.features.onboarding.OnboardingAction +import im.vector.app.features.onboarding.OnboardingViewState +import javax.inject.Inject + +/** + * In this screen, the user is asked to sign up or to sign in to the homeserver + */ +class FtueAuthSignUpSignInSelectionFragment @Inject constructor() : AbstractSSOFtueAuthFragment() { + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginSignupSigninSelectionBinding { + return FragmentLoginSignupSigninSelectionBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupViews() + } + + private fun setupViews() { + views.loginSignupSigninSubmit.setOnClickListener { submit() } + views.loginSignupSigninSignIn.setOnClickListener { signIn() } + } + + private fun setupUi(state: OnboardingViewState) { + when (state.serverType) { + ServerType.MatrixOrg -> { + views.loginSignupSigninServerIcon.setImageResource(R.drawable.ic_logo_matrix_org) + views.loginSignupSigninServerIcon.isVisible = true + views.loginSignupSigninTitle.text = getString(R.string.login_connect_to, state.homeServerUrlFromUser.toReducedUrl()) + views.loginSignupSigninText.text = getString(R.string.login_server_matrix_org_text) + } + ServerType.EMS -> { + views.loginSignupSigninServerIcon.setImageResource(R.drawable.ic_logo_element_matrix_services) + views.loginSignupSigninServerIcon.isVisible = true + views.loginSignupSigninTitle.text = getString(R.string.login_connect_to_modular) + views.loginSignupSigninText.text = state.homeServerUrlFromUser.toReducedUrl() + } + ServerType.Other -> { + views.loginSignupSigninServerIcon.isVisible = false + views.loginSignupSigninTitle.text = getString(R.string.login_server_other_title) + views.loginSignupSigninText.text = getString(R.string.login_connect_to, state.homeServerUrlFromUser.toReducedUrl()) + } + ServerType.Unknown -> Unit /* Should not happen */ + } + + when (state.loginMode) { + is LoginMode.SsoAndPassword -> { + views.loginSignupSigninSignInSocialLoginContainer.isVisible = true + views.loginSignupSigninSocialLoginButtons.ssoIdentityProviders = state.loginMode.ssoIdentityProviders()?.sorted() + views.loginSignupSigninSocialLoginButtons.listener = object : SocialLoginButtonsView.InteractionListener { + override fun onProviderSelected(id: String?) { + viewModel.getSsoUrl( + redirectUrl = SSORedirectRouterActivity.VECTOR_REDIRECT_URL, + deviceId = state.deviceId, + providerId = id + ) + ?.let { openInCustomTab(it) } + } + } + } + else -> { + // SSO only is managed without container as well as No sso + views.loginSignupSigninSignInSocialLoginContainer.isVisible = false + views.loginSignupSigninSocialLoginButtons.ssoIdentityProviders = null + } + } + } + + private fun setupButtons(state: OnboardingViewState) { + when (state.loginMode) { + is LoginMode.Sso -> { + // change to only one button that is sign in with sso + views.loginSignupSigninSubmit.text = getString(R.string.login_signin_sso) + views.loginSignupSigninSignIn.isVisible = false + } + else -> { + views.loginSignupSigninSubmit.text = getString(R.string.login_signup) + views.loginSignupSigninSignIn.isVisible = true + } + } + } + + private fun submit() = withState(viewModel) { state -> + if (state.loginMode is LoginMode.Sso) { + viewModel.getSsoUrl( + redirectUrl = SSORedirectRouterActivity.VECTOR_REDIRECT_URL, + deviceId = state.deviceId, + providerId = null + ) + ?.let { openInCustomTab(it) } + } else { + viewModel.handle(OnboardingAction.UpdateSignMode(SignMode.SignUp)) + } + } + + private fun signIn() { + viewModel.handle(OnboardingAction.UpdateSignMode(SignMode.SignIn)) + } + + override fun resetViewModel() { + viewModel.handle(OnboardingAction.ResetSignMode) + } + + override fun updateWithState(state: OnboardingViewState) { + setupUi(state) + setupButtons(state) + } +} diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSplashFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSplashFragment.kt new file mode 100644 index 0000000000..06bba82a2d --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSplashFragment.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.onboarding.ftueauth + +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 com.google.android.material.dialog.MaterialAlertDialogBuilder +import im.vector.app.BuildConfig +import im.vector.app.R +import im.vector.app.databinding.FragmentLoginSplashBinding +import im.vector.app.features.login.AbstractLoginFragment +import im.vector.app.features.login.LoginAction +import im.vector.app.features.settings.VectorPreferences +import org.matrix.android.sdk.api.failure.Failure +import java.net.UnknownHostException +import javax.inject.Inject + +/** + * In this screen, the user is viewing an introduction to what he can do with this application + */ +class FtueAuthSplashFragment @Inject constructor( + private val vectorPreferences: VectorPreferences +) : AbstractLoginFragment() { + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginSplashBinding { + return FragmentLoginSplashBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupViews() + } + + private fun setupViews() { + views.loginSplashSubmit.debouncedClicks { getStarted() } + + 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 getStarted() { + loginViewModel.handle(LoginAction.OnGetStarted(resetLoginConfig = false)) + } + + override fun resetViewModel() { + // Nothing to do + } + + override fun onError(throwable: Throwable) { + if (throwable is Failure.NetworkConnection && + throwable.ioException is UnknownHostException) { + // Invalid homeserver from URL config + val url = loginViewModel.getInitialHomeServerUrl().orEmpty() + MaterialAlertDialogBuilder(requireActivity()) + .setTitle(R.string.dialog_title_error) + .setMessage(getString(R.string.login_error_homeserver_from_url_not_found, url)) + .setPositiveButton(R.string.login_error_homeserver_from_url_not_found_enter_manual) { _, _ -> + loginViewModel.handle(LoginAction.OnGetStarted(resetLoginConfig = true)) + } + .setNegativeButton(R.string.action_cancel, null) + .show() + } else { + super.onError(throwable) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthWaitForEmailFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthWaitForEmailFragment.kt new file mode 100644 index 0000000000..a90d919c05 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthWaitForEmailFragment.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.onboarding.ftueauth + +import android.os.Bundle +import android.os.Parcelable +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.FragmentLoginWaitForEmailBinding +import im.vector.app.features.onboarding.OnboardingAction +import kotlinx.parcelize.Parcelize +import org.matrix.android.sdk.api.failure.is401 +import javax.inject.Inject + +@Parcelize +data class FtueAuthWaitForEmailFragmentArgument( + val email: String +) : Parcelable + +/** + * In this screen, the user is asked to check his emails + */ +class FtueAuthWaitForEmailFragment @Inject constructor() : AbstractFtueAuthFragment() { + + private val params: FtueAuthWaitForEmailFragmentArgument by args() + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginWaitForEmailBinding { + return FragmentLoginWaitForEmailBinding.inflate(inflater, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setupUi() + } + + override fun onResume() { + super.onResume() + + viewModel.handle(OnboardingAction.CheckIfEmailHasBeenValidated(0)) + } + + override fun onPause() { + super.onPause() + + viewModel.handle(OnboardingAction.StopEmailValidationCheck) + } + + private fun setupUi() { + views.loginWaitForEmailNotice.text = getString(R.string.login_wait_for_email_notice, params.email) + } + + override fun onError(throwable: Throwable) { + if (throwable.is401()) { + // Try again, with a delay + viewModel.handle(OnboardingAction.CheckIfEmailHasBeenValidated(10_000)) + } else { + super.onError(throwable) + } + } + + override fun resetViewModel() { + viewModel.handle(OnboardingAction.ResetLogin) + } +} diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthWebFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthWebFragment.kt new file mode 100644 index 0000000000..d479439a0f --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthWebFragment.kt @@ -0,0 +1,259 @@ +/* + * 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.onboarding.ftueauth + +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 com.airbnb.mvrx.activityViewModel +import com.google.android.material.dialog.MaterialAlertDialogBuilder +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.login.SignMode +import im.vector.app.features.onboarding.OnboardingAction +import im.vector.app.features.onboarding.OnboardingViewEvents +import im.vector.app.features.onboarding.OnboardingViewState +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 FtueAuthWebFragment @Inject constructor( + private val assetReader: AssetReader +) : AbstractFtueAuthFragment() { + + val softLogoutViewModel: SoftLogoutViewModel by activityViewModel() + + 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: OnboardingViewState) { + setupTitle(state) + + isForSessionRecovery = state.deviceId?.isNotBlank() == true + + if (!isWebViewLoaded) { + setupWebView(state) + isWebViewLoaded = true + } + } + + private fun setupTitle(state: OnboardingViewState) { + views.loginWebToolbar.title = when (state.signMode) { + SignMode.SignIn -> getString(R.string.login_signin) + else -> getString(R.string.login_signup) + } + } + + @SuppressLint("SetJavaScriptEnabled") + private fun setupWebView(state: OnboardingViewState) { + 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: OnboardingViewState) { + val url = viewModel.getFallbackUrl(state.signMode == SignMode.SignIn, state.deviceId) ?: return + + views.loginWebWebView.loadUrl(url) + + views.loginWebWebView.webViewClient = object : WebViewClient() { + override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, + error: SslError) { + MaterialAlertDialogBuilder(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) + + viewModel.handle(OnboardingAction.PostViewEvent(OnboardingViewEvents.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 == SignMode.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 == SignMode.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) { + softLogoutViewModel.handle(SoftLogoutAction.WebLoginSuccess(credentials)) + } else { + viewModel.handle(OnboardingAction.WebLoginSuccess(credentials)) + } + } + + override fun resetViewModel() { + viewModel.handle(OnboardingAction.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/onboarding/ftueauth/terms/FtueAuthTermsFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/terms/FtueAuthTermsFragment.kt new file mode 100755 index 0000000000..5ce9a5350d --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/terms/FtueAuthTermsFragment.kt @@ -0,0 +1,125 @@ +/* + * 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.onboarding.ftueauth.terms + +import android.os.Bundle +import android.os.Parcelable +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.LoginTermsViewState +import im.vector.app.features.login.terms.PolicyController +import im.vector.app.features.onboarding.OnboardingAction +import im.vector.app.features.onboarding.OnboardingViewState +import im.vector.app.features.onboarding.ftueauth.AbstractFtueAuthFragment +import kotlinx.parcelize.Parcelize +import org.matrix.android.sdk.internal.auth.registration.LocalizedFlowDataLoginTerms +import javax.inject.Inject + +@Parcelize +data class FtueAuthTermsFragmentArgument( + val localizedFlowDataLoginTerms: List +) : Parcelable + +/** + * LoginTermsFragment displays the list of policies the user has to accept + */ +class FtueAuthTermsFragment @Inject constructor( + private val policyController: PolicyController +) : AbstractFtueAuthFragment(), + PolicyController.PolicyControllerListener { + + private val params: FtueAuthTermsFragmentArgument 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() { + viewModel.handle(OnboardingAction.AcceptTerms) + } + + override fun updateWithState(state: OnboardingViewState) { + policyController.homeServer = state.homeServerUrlFromUser.toReducedUrl() + renderState() + } + + override fun resetViewModel() { + viewModel.handle(OnboardingAction.ResetLogin) + } +}