copying all of the login fragments into the ftue auth domain

This commit is contained in:
Adam Brown 2021-12-09 14:42:45 +00:00
parent 355d8ebdc6
commit 8d819c76ab
16 changed files with 2335 additions and 48 deletions

View File

@ -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<View?>(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

View File

@ -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<VB : ViewBinding> : VectorBaseFragment<VB>(), 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()
}

View File

@ -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<VB : ViewBinding> : AbstractFtueAuthFragment<VB>() {
// 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) }
}
}
}
}

View File

@ -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<FragmentLoginCaptchaBinding>() {
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
}
}
}

View File

@ -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<FragmentLoginGenericTextInputFormBinding>() {
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)
}
}

View File

@ -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<FragmentLoginBinding>() {
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 }
}

View File

@ -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<FragmentLoginResetPasswordBinding>() {
// 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.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
}
}
}

View File

@ -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<FragmentLoginResetPasswordMailConfirmationBinding>() {
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
}
}
}

View File

@ -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<FragmentLoginResetPasswordSuccessBinding>() {
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)
}
}

View File

@ -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<FragmentLoginServerSelectionBinding>() {
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)
}
}

View File

@ -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<FragmentLoginServerUrlFormBinding>() {
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()
}
}

View File

@ -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<FragmentLoginSignupSigninSelectionBinding>() {
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)
}
}

View File

@ -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<FragmentLoginSplashBinding>() {
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.cancel, null)
.show()
} else {
super.onError(throwable)
}
}
}

View File

@ -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<FragmentLoginWaitForEmailBinding>() {
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)
}
}

View File

@ -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<FragmentLoginWebBinding>() {
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:
*
* <pre>
* 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/"
* }
* }
* }
* }
* </pre>
* @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)
}
}
}

View File

@ -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<LocalizedFlowDataLoginTerms>
) : Parcelable
/**
* LoginTermsFragment displays the list of policies the user has to accept
*/
class FtueAuthTermsFragment @Inject constructor(
private val policyController: PolicyController
) : AbstractFtueAuthFragment<FragmentLoginTermsBinding>(),
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<LocalizedFlowDataLoginTermsChecked>()
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)
}
}