Merge pull request #5868 from vector-im/feature/adm/ftue-email-verification

[FTUE] - Email input and verification
This commit is contained in:
Adam Brown 2022-05-23 11:14:46 +01:00 committed by GitHub
commit 98999c754f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 808 additions and 96 deletions

1
changelog.d/5278.wip Normal file
View File

@ -0,0 +1 @@
Adds email input and verification screens to the new FTUE onboarding flow

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape>
<gradient
android:angle="@integer/rtl_mirror_flip"
android:endColor="#55DFD1FF"
android:startColor="#55A5F2E0" />
</shape>
</item>
<item>
<shape>
<gradient
android:angle="90"
android:endColor="@android:color/transparent"
android:startColor="?android:colorBackground" />
</shape>
</item>
</layer-list>

View File

@ -101,8 +101,10 @@ import im.vector.app.features.onboarding.ftueauth.FtueAuthAccountCreatedFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthCaptchaFragment import im.vector.app.features.onboarding.ftueauth.FtueAuthCaptchaFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthChooseDisplayNameFragment import im.vector.app.features.onboarding.ftueauth.FtueAuthChooseDisplayNameFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthChooseProfilePictureFragment import im.vector.app.features.onboarding.ftueauth.FtueAuthChooseProfilePictureFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthEmailEntryFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthGenericTextInputFormFragment import im.vector.app.features.onboarding.ftueauth.FtueAuthGenericTextInputFormFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthLegacyStyleCaptchaFragment import im.vector.app.features.onboarding.ftueauth.FtueAuthLegacyStyleCaptchaFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthLegacyWaitForEmailFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthLoginFragment import im.vector.app.features.onboarding.ftueauth.FtueAuthLoginFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthPersonalizationCompleteFragment import im.vector.app.features.onboarding.ftueauth.FtueAuthPersonalizationCompleteFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthResetPasswordFragment import im.vector.app.features.onboarding.ftueauth.FtueAuthResetPasswordFragment
@ -474,6 +476,11 @@ interface FragmentModule {
@FragmentKey(FtueAuthWaitForEmailFragment::class) @FragmentKey(FtueAuthWaitForEmailFragment::class)
fun bindFtueAuthWaitForEmailFragment(fragment: FtueAuthWaitForEmailFragment): Fragment fun bindFtueAuthWaitForEmailFragment(fragment: FtueAuthWaitForEmailFragment): Fragment
@Binds
@IntoMap
@FragmentKey(FtueAuthLegacyWaitForEmailFragment::class)
fun bindFtueAuthLegacyWaitForEmailFragment(fragment: FtueAuthLegacyWaitForEmailFragment): Fragment
@Binds @Binds
@IntoMap @IntoMap
@FragmentKey(FtueAuthWebFragment::class) @FragmentKey(FtueAuthWebFragment::class)
@ -494,6 +501,11 @@ interface FragmentModule {
@FragmentKey(FtueAuthAccountCreatedFragment::class) @FragmentKey(FtueAuthAccountCreatedFragment::class)
fun bindFtueAuthAccountCreatedFragment(fragment: FtueAuthAccountCreatedFragment): Fragment fun bindFtueAuthAccountCreatedFragment(fragment: FtueAuthAccountCreatedFragment): Fragment
@Binds
@IntoMap
@FragmentKey(FtueAuthEmailEntryFragment::class)
fun bindFtueAuthEmailEntryFragment(fragment: FtueAuthEmailEntryFragment): Fragment
@Binds @Binds
@IntoMap @IntoMap
@FragmentKey(FtueAuthChooseDisplayNameFragment::class) @FragmentKey(FtueAuthChooseDisplayNameFragment::class)

View File

@ -0,0 +1,33 @@
/*
* Copyright (c) 2022 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.core.extensions
import kotlinx.coroutines.Job
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
/**
* Property delegate for automatically cancelling the current job when setting a new value.
*/
fun cancelCurrentOnSet(): ReadWriteProperty<Any?, Job?> = object : ReadWriteProperty<Any?, Job?> {
private var currentJob: Job? = null
override fun getValue(thisRef: Any?, property: KProperty<*>): Job? = currentJob
override fun setValue(thisRef: Any?, property: KProperty<*>, value: Job?) {
currentJob?.cancel()
currentJob = value
}
}

View File

@ -16,7 +16,11 @@
package im.vector.app.core.extensions package im.vector.app.core.extensions
import android.text.Editable
import android.view.View
import android.view.inputmethod.EditorInfo
import com.google.android.material.textfield.TextInputLayout import com.google.android.material.textfield.TextInputLayout
import im.vector.app.core.platform.SimpleTextWatcher
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import reactivecircus.flowbinding.android.widget.textChanges import reactivecircus.flowbinding.android.widget.textChanges
@ -30,3 +34,26 @@ fun TextInputLayout.hasSurroundingSpaces() = editText().text.toString().let { it
fun TextInputLayout.hasContentFlow(mapper: (CharSequence) -> CharSequence = { it }) = editText().textChanges().map { mapper(it).isNotEmpty() } fun TextInputLayout.hasContentFlow(mapper: (CharSequence) -> CharSequence = { it }) = editText().textChanges().map { mapper(it).isNotEmpty() }
fun TextInputLayout.content() = editText().text.toString() fun TextInputLayout.content() = editText().text.toString()
fun TextInputLayout.hasContent() = !editText().text.isNullOrEmpty()
fun TextInputLayout.associateContentStateWith(button: View) {
editText().addTextChangedListener(object : SimpleTextWatcher() {
override fun afterTextChanged(s: Editable) {
val newContent = s.toString()
button.isEnabled = newContent.isNotEmpty()
}
})
}
fun TextInputLayout.setOnImeDoneListener(action: () -> Unit) {
editText().setOnEditorActionListener { _, actionId, _ ->
when (actionId) {
EditorInfo.IME_ACTION_DONE -> {
action()
true
}
else -> false
}
}
}

View File

@ -22,6 +22,7 @@ import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan import android.text.style.StyleSpan
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import me.gujun.android.span.Span import me.gujun.android.span.Span
import me.gujun.android.span.span
fun Spannable.styleMatchingText(match: String, typeFace: Int): Spannable { fun Spannable.styleMatchingText(match: String, typeFace: Int): Spannable {
if (match.isEmpty()) return this if (match.isEmpty()) return this
@ -56,3 +57,17 @@ fun Span.bullet(text: CharSequence = "",
build() build()
}) })
} }
fun String.colorTerminatingFullStop(@ColorInt color: Int): CharSequence {
val fullStop = "."
return if (endsWith(fullStop)) {
span {
+this@colorTerminatingFullStop.removeSuffix(fullStop)
span(fullStop) {
textColor = color
}
}
} else {
this
}
}

View File

@ -25,6 +25,7 @@ import im.vector.app.R
import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.extensions.cancelCurrentOnSet
import im.vector.app.core.extensions.configureAndStart import im.vector.app.core.extensions.configureAndStart
import im.vector.app.core.extensions.vectorStore import im.vector.app.core.extensions.vectorStore
import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModel
@ -50,7 +51,6 @@ import org.matrix.android.sdk.api.auth.HomeServerHistoryService
import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
import org.matrix.android.sdk.api.auth.login.LoginWizard import org.matrix.android.sdk.api.auth.login.LoginWizard
import org.matrix.android.sdk.api.auth.registration.FlowResult import org.matrix.android.sdk.api.auth.registration.FlowResult
import org.matrix.android.sdk.api.auth.registration.RegistrationResult
import org.matrix.android.sdk.api.auth.registration.RegistrationWizard import org.matrix.android.sdk.api.auth.registration.RegistrationWizard
import org.matrix.android.sdk.api.auth.registration.Stage import org.matrix.android.sdk.api.auth.registration.Stage
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
@ -125,12 +125,8 @@ class OnboardingViewModel @AssistedInject constructor(
private var loginConfig: LoginConfig? = null private var loginConfig: LoginConfig? = null
private var currentJob: Job? = null private var emailVerificationPollingJob: Job? by cancelCurrentOnSet()
set(value) { private var currentJob: Job? by cancelCurrentOnSet()
// Cancel any previous Job
field?.cancel()
field = value
}
override fun handle(action: OnboardingAction) { override fun handle(action: OnboardingAction) {
when (action) { when (action) {
@ -257,13 +253,19 @@ class OnboardingViewModel @AssistedInject constructor(
} }
private fun handleRegisterAction(action: RegisterAction, onNextRegistrationStepAction: (FlowResult) -> Unit) { private fun handleRegisterAction(action: RegisterAction, onNextRegistrationStepAction: (FlowResult) -> Unit) {
currentJob = viewModelScope.launch { val job = viewModelScope.launch {
if (action.hasLoadingState()) { if (action.hasLoadingState()) {
setState { copy(isLoading = true) } setState { copy(isLoading = true) }
} }
internalRegisterAction(action, onNextRegistrationStepAction) internalRegisterAction(action, onNextRegistrationStepAction)
setState { copy(isLoading = false) } setState { copy(isLoading = false) }
} }
// Allow email verification polling to coexist with other jobs
when (action) {
is RegisterAction.CheckIfEmailHasBeenValidated -> emailVerificationPollingJob = job
else -> currentJob = job
}
} }
private suspend fun internalRegisterAction(action: RegisterAction, onNextRegistrationStepAction: (FlowResult) -> Unit) { private suspend fun internalRegisterAction(action: RegisterAction, onNextRegistrationStepAction: (FlowResult) -> Unit) {
@ -275,8 +277,10 @@ class OnboardingViewModel @AssistedInject constructor(
// do nothing // do nothing
} }
else -> when (it) { else -> when (it) {
is RegistrationResult.Success -> onSessionCreated(it.session, isAccountCreated = true) is RegistrationResult.Complete -> onSessionCreated(it.session, isAccountCreated = true)
is RegistrationResult.FlowResponse -> onFlowResponse(it.flowResult, onNextRegistrationStepAction) is RegistrationResult.NextStep -> onFlowResponse(it.flowResult, onNextRegistrationStepAction)
is RegistrationResult.SendEmailSuccess -> _viewEvents.post(OnboardingViewEvents.OnSendEmailSuccess(it.email))
is RegistrationResult.Error -> _viewEvents.post(OnboardingViewEvents.Failure(it.cause))
} }
} }
}, },
@ -307,6 +311,7 @@ class OnboardingViewModel @AssistedInject constructor(
private fun handleResetAction(action: OnboardingAction.ResetAction) { private fun handleResetAction(action: OnboardingAction.ResetAction) {
// Cancel any request // Cancel any request
currentJob = null currentJob = null
emailVerificationPollingJob = null
when (action) { when (action) {
OnboardingAction.ResetHomeServerType -> { OnboardingAction.ResetHomeServerType -> {
@ -790,7 +795,7 @@ class OnboardingViewModel @AssistedInject constructor(
} }
private fun cancelWaitForEmailValidation() { private fun cancelWaitForEmailValidation() {
currentJob = null emailVerificationPollingJob = null
} }
} }

View File

@ -16,26 +16,80 @@
package im.vector.app.features.onboarding package im.vector.app.features.onboarding
import org.matrix.android.sdk.api.auth.registration.FlowResult
import org.matrix.android.sdk.api.auth.registration.RegisterThreePid import org.matrix.android.sdk.api.auth.registration.RegisterThreePid
import org.matrix.android.sdk.api.auth.registration.RegistrationResult import org.matrix.android.sdk.api.auth.registration.RegistrationResult.FlowResponse
import org.matrix.android.sdk.api.auth.registration.RegistrationResult.Success
import org.matrix.android.sdk.api.auth.registration.RegistrationWizard import org.matrix.android.sdk.api.auth.registration.RegistrationWizard
import org.matrix.android.sdk.api.failure.is401
import org.matrix.android.sdk.api.session.Session
import javax.inject.Inject import javax.inject.Inject
import org.matrix.android.sdk.api.auth.registration.RegistrationResult as MatrixRegistrationResult
class RegistrationActionHandler @Inject constructor() { class RegistrationActionHandler @Inject constructor() {
suspend fun handleRegisterAction(registrationWizard: RegistrationWizard, action: RegisterAction): RegistrationResult { suspend fun handleRegisterAction(registrationWizard: RegistrationWizard, action: RegisterAction): RegistrationResult {
return when (action) { return when (action) {
RegisterAction.StartRegistration -> registrationWizard.getRegistrationFlow() RegisterAction.StartRegistration -> resultOf { registrationWizard.getRegistrationFlow() }
is RegisterAction.CaptchaDone -> registrationWizard.performReCaptcha(action.captchaResponse) is RegisterAction.CaptchaDone -> resultOf { registrationWizard.performReCaptcha(action.captchaResponse) }
is RegisterAction.AcceptTerms -> registrationWizard.acceptTerms() is RegisterAction.AcceptTerms -> resultOf { registrationWizard.acceptTerms() }
is RegisterAction.RegisterDummy -> registrationWizard.dummy() is RegisterAction.RegisterDummy -> resultOf { registrationWizard.dummy() }
is RegisterAction.AddThreePid -> registrationWizard.addThreePid(action.threePid) is RegisterAction.AddThreePid -> handleAddThreePid(registrationWizard, action)
is RegisterAction.SendAgainThreePid -> registrationWizard.sendAgainThreePid() is RegisterAction.SendAgainThreePid -> resultOf { registrationWizard.sendAgainThreePid() }
is RegisterAction.ValidateThreePid -> registrationWizard.handleValidateThreePid(action.code) is RegisterAction.ValidateThreePid -> resultOf { registrationWizard.handleValidateThreePid(action.code) }
is RegisterAction.CheckIfEmailHasBeenValidated -> registrationWizard.checkIfEmailHasBeenValidated(action.delayMillis) is RegisterAction.CheckIfEmailHasBeenValidated -> handleCheckIfEmailIsValidated(registrationWizard, action.delayMillis)
is RegisterAction.CreateAccount -> registrationWizard.createAccount(action.username, action.password, action.initialDeviceName) is RegisterAction.CreateAccount -> resultOf {
registrationWizard.createAccount(
action.username,
action.password,
action.initialDeviceName
)
} }
} }
}
private suspend fun handleAddThreePid(wizard: RegistrationWizard, action: RegisterAction.AddThreePid): RegistrationResult {
return runCatching { wizard.addThreePid(action.threePid) }.fold(
onSuccess = { it.toRegistrationResult() },
onFailure = {
when {
action.threePid is RegisterThreePid.Email && it.is401() -> RegistrationResult.SendEmailSuccess(action.threePid.email)
else -> RegistrationResult.Error(it)
}
}
)
}
private tailrec suspend fun handleCheckIfEmailIsValidated(registrationWizard: RegistrationWizard, delayMillis: Long): RegistrationResult {
return runCatching { registrationWizard.checkIfEmailHasBeenValidated(delayMillis) }.fold(
onSuccess = { it.toRegistrationResult() },
onFailure = {
when {
it.is401() -> null // recursively continue to check with a delay
else -> RegistrationResult.Error(it)
}
}
) ?: handleCheckIfEmailIsValidated(registrationWizard, 10_000)
}
}
private inline fun resultOf(block: () -> MatrixRegistrationResult): RegistrationResult {
return runCatching { block() }.fold(
onSuccess = { it.toRegistrationResult() },
onFailure = { RegistrationResult.Error(it) }
)
}
private fun MatrixRegistrationResult.toRegistrationResult() = when (this) {
is FlowResponse -> RegistrationResult.NextStep(flowResult)
is Success -> RegistrationResult.Complete(session)
}
sealed interface RegistrationResult {
data class Error(val cause: Throwable) : RegistrationResult
data class Complete(val session: Session) : RegistrationResult
data class NextStep(val flowResult: FlowResult) : RegistrationResult
data class SendEmailSuccess(val email: String) : RegistrationResult
} }
sealed interface RegisterAction { sealed interface RegisterAction {
@ -56,7 +110,6 @@ sealed interface RegisterAction {
} }
fun RegisterAction.ignoresResult() = when (this) { fun RegisterAction.ignoresResult() = when (this) {
is RegisterAction.AddThreePid -> true
is RegisterAction.SendAgainThreePid -> true is RegisterAction.SendAgainThreePid -> true
else -> false else -> false
} }

View File

@ -22,7 +22,7 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import com.google.android.material.textfield.TextInputLayout import im.vector.app.core.extensions.hasContent
import im.vector.app.core.platform.SimpleTextWatcher import im.vector.app.core.platform.SimpleTextWatcher
import im.vector.app.databinding.FragmentFtueDisplayNameBinding import im.vector.app.databinding.FragmentFtueDisplayNameBinding
import im.vector.app.features.onboarding.OnboardingAction import im.vector.app.features.onboarding.OnboardingAction
@ -69,7 +69,7 @@ class FtueAuthChooseDisplayNameFragment @Inject constructor() : AbstractFtueAuth
override fun updateWithState(state: OnboardingViewState) { override fun updateWithState(state: OnboardingViewState) {
views.displayNameInput.editText?.setText(state.personalizationState.displayName) views.displayNameInput.editText?.setText(state.personalizationState.displayName)
views.displayNameSubmit.isEnabled = views.displayNameInput.hasContentEmpty() views.displayNameSubmit.isEnabled = views.displayNameInput.hasContent()
} }
override fun resetViewModel() { override fun resetViewModel() {
@ -81,5 +81,3 @@ class FtueAuthChooseDisplayNameFragment @Inject constructor() : AbstractFtueAuth
return true return true
} }
} }
private fun TextInputLayout.hasContentEmpty() = !editText?.text.isNullOrEmpty()

View File

@ -68,7 +68,7 @@ class FtueAuthCombinedServerSelectionFragment @Inject constructor() : AbstractFt
views.chooseServerSubmit.debouncedClicks { updateServerUrl() } views.chooseServerSubmit.debouncedClicks { updateServerUrl() }
views.chooseServerInput.editText().textChanges() views.chooseServerInput.editText().textChanges()
.onEach { views.chooseServerInput.error = null } .onEach { views.chooseServerInput.error = null }
.launchIn(lifecycleScope) .launchIn(viewLifecycleOwner.lifecycleScope)
} }
private fun updateServerUrl() { private fun updateServerUrl() {

View File

@ -0,0 +1,74 @@
/*
* Copyright (c) 2022 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 im.vector.app.core.extensions.associateContentStateWith
import im.vector.app.core.extensions.content
import im.vector.app.core.extensions.editText
import im.vector.app.core.extensions.isEmail
import im.vector.app.core.extensions.setOnImeDoneListener
import im.vector.app.databinding.FragmentFtueEmailInputBinding
import im.vector.app.features.onboarding.OnboardingAction
import im.vector.app.features.onboarding.RegisterAction
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.matrix.android.sdk.api.auth.registration.RegisterThreePid
import reactivecircus.flowbinding.android.widget.textChanges
import javax.inject.Inject
class FtueAuthEmailEntryFragment @Inject constructor() : AbstractFtueAuthFragment<FragmentFtueEmailInputBinding>() {
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFtueEmailInputBinding {
return FragmentFtueEmailInputBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupViews()
}
private fun setupViews() {
views.emailEntryInput.associateContentStateWith(button = views.emailEntrySubmit)
views.emailEntryInput.setOnImeDoneListener { updateEmail() }
views.emailEntrySubmit.debouncedClicks { updateEmail() }
views.emailEntryInput.editText().textChanges()
.onEach {
views.emailEntryInput.error = null
views.emailEntrySubmit.isEnabled = it.isEmail()
}
.launchIn(viewLifecycleOwner.lifecycleScope)
}
private fun updateEmail() {
val email = views.emailEntryInput.content()
viewModel.handle(OnboardingAction.PostRegisterAction(RegisterAction.AddThreePid(RegisterThreePid.Email(email))))
}
override fun onError(throwable: Throwable) {
views.emailEntryInput.error = errorFormatter.toHumanReadable(throwable)
}
override fun resetViewModel() {
viewModel.handle(OnboardingAction.ResetAuthenticationAttempt)
}
}

View File

@ -223,13 +223,8 @@ class FtueAuthGenericTextInputFormFragment @Inject constructor() : AbstractFtueA
override fun onError(throwable: Throwable) { override fun onError(throwable: Throwable) {
when (params.mode) { when (params.mode) {
TextInputFormFragmentMode.SetEmail -> { 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) views.loginGenericTextInputFormTil.error = errorFormatter.toHumanReadable(throwable)
} }
}
TextInputFormFragmentMode.SetMsisdn -> { TextInputFormFragmentMode.SetMsisdn -> {
if (throwable.is401()) { if (throwable.is401()) {
// This is normal use case, we go to the enter code screen // This is normal use case, we go to the enter code screen

View File

@ -0,0 +1,63 @@
/*
* Copyright (c) 2022 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.args
import im.vector.app.R
import im.vector.app.databinding.FragmentLoginWaitForEmailBinding
import im.vector.app.features.onboarding.OnboardingAction
import im.vector.app.features.onboarding.RegisterAction
import javax.inject.Inject
/**
* In this screen, the user is asked to check their emails.
*/
class FtueAuthLegacyWaitForEmailFragment @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.PostRegisterAction(RegisterAction.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 resetViewModel() {
viewModel.handle(OnboardingAction.ResetAuthenticationAttempt)
}
}

View File

@ -22,6 +22,8 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
import com.airbnb.mvrx.withState import com.airbnb.mvrx.withState
@ -90,7 +92,7 @@ class FtueAuthSplashCarouselFragment @Inject constructor(
private fun ViewPager2.registerAutomaticUntilInteractionTransitions() { private fun ViewPager2.registerAutomaticUntilInteractionTransitions() {
var scheduledTransition: Job? = null var scheduledTransition: Job? = null
registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { val pageChangingCallback = object : ViewPager2.OnPageChangeCallback() {
private var hasUserManuallyInteractedWithCarousel: Boolean = false private var hasUserManuallyInteractedWithCarousel: Boolean = false
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) { override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
@ -104,12 +106,21 @@ class FtueAuthSplashCarouselFragment @Inject constructor(
scheduledTransition = scheduleCarouselTransition() scheduledTransition = scheduleCarouselTransition()
} }
} }
}
viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onCreate(owner: LifecycleOwner) {
registerOnPageChangeCallback(pageChangingCallback)
}
override fun onDestroy(owner: LifecycleOwner) {
unregisterOnPageChangeCallback(pageChangingCallback)
}
}) })
} }
private fun ViewPager2.scheduleCarouselTransition(): Job { private fun ViewPager2.scheduleCarouselTransition(): Job {
val itemCount = adapter?.itemCount ?: throw IllegalStateException("An adapter must be set") val itemCount = adapter?.itemCount ?: throw IllegalStateException("An adapter must be set")
return lifecycleScope.launch { return viewLifecycleOwner.lifecycleScope.launch {
delay(CAROUSEL_ROTATION_DELAY_MS) delay(CAROUSEL_ROTATION_DELAY_MS)
setCurrentItem(currentItem.incrementByOneAndWrap(max = itemCount - 1), duration = CAROUSEL_TRANSITION_TIME_MS) setCurrentItem(currentItem.incrementByOneAndWrap(max = itemCount - 1), duration = CAROUSEL_TRANSITION_TIME_MS)
} }

View File

@ -192,12 +192,7 @@ class FtueAuthVariant(
supportFragmentManager.popBackStack(FRAGMENT_LOGIN_TAG, POP_BACK_STACK_EXCLUSIVE) supportFragmentManager.popBackStack(FRAGMENT_LOGIN_TAG, POP_BACK_STACK_EXCLUSIVE)
} }
is OnboardingViewEvents.OnSendEmailSuccess -> { is OnboardingViewEvents.OnSendEmailSuccess -> {
// Pop the enter email Fragment openWaitForEmailVerification(viewEvents.email)
supportFragmentManager.popBackStack(FRAGMENT_REGISTRATION_STAGE_TAG, FragmentManager.POP_BACK_STACK_INCLUSIVE)
addRegistrationStageFragmentToBackstack(
FtueAuthWaitForEmailFragment::class.java,
FtueAuthWaitForEmailFragmentArgument(viewEvents.email),
)
} }
is OnboardingViewEvents.OnSendMsisdnSuccess -> { is OnboardingViewEvents.OnSendMsisdnSuccess -> {
// Pop the enter Msisdn Fragment // Pop the enter Msisdn Fragment
@ -393,10 +388,7 @@ class FtueAuthVariant(
when (stage) { when (stage) {
is Stage.ReCaptcha -> onCaptcha(stage) is Stage.ReCaptcha -> onCaptcha(stage)
is Stage.Email -> addRegistrationStageFragmentToBackstack( is Stage.Email -> onEmail(stage)
FtueAuthGenericTextInputFormFragment::class.java,
FtueAuthGenericTextInputFormFragmentArgument(TextInputFormFragmentMode.SetEmail, stage.mandatory),
)
is Stage.Msisdn -> addRegistrationStageFragmentToBackstack( is Stage.Msisdn -> addRegistrationStageFragmentToBackstack(
FtueAuthGenericTextInputFormFragment::class.java, FtueAuthGenericTextInputFormFragment::class.java,
FtueAuthGenericTextInputFormFragmentArgument(TextInputFormFragmentMode.SetMsisdn, stage.mandatory), FtueAuthGenericTextInputFormFragmentArgument(TextInputFormFragmentMode.SetMsisdn, stage.mandatory),
@ -406,6 +398,32 @@ class FtueAuthVariant(
} }
} }
private fun onEmail(stage: Stage) {
when {
vectorFeatures.isOnboardingCombinedRegisterEnabled() -> addRegistrationStageFragmentToBackstack(
FtueAuthEmailEntryFragment::class.java
)
else -> addRegistrationStageFragmentToBackstack(
FtueAuthGenericTextInputFormFragment::class.java,
FtueAuthGenericTextInputFormFragmentArgument(TextInputFormFragmentMode.SetEmail, stage.mandatory),
)
}
}
private fun openWaitForEmailVerification(email: String) {
supportFragmentManager.popBackStack(FRAGMENT_REGISTRATION_STAGE_TAG, FragmentManager.POP_BACK_STACK_INCLUSIVE)
when {
vectorFeatures.isOnboardingCombinedRegisterEnabled() -> addRegistrationStageFragmentToBackstack(
FtueAuthWaitForEmailFragment::class.java,
FtueAuthWaitForEmailFragmentArgument(email),
)
else -> addRegistrationStageFragmentToBackstack(
FtueAuthLegacyWaitForEmailFragment::class.java,
FtueAuthWaitForEmailFragmentArgument(email),
)
}
}
private fun onTerms(stage: Stage.Terms) { private fun onTerms(stage: Stage.Terms) {
when { when {
vectorFeatures.isOnboardingCombinedRegisterEnabled() -> addRegistrationStageFragmentToBackstack( vectorFeatures.isOnboardingCombinedRegisterEnabled() -> addRegistrationStageFragmentToBackstack(

View File

@ -21,13 +21,16 @@ import android.os.Parcelable
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible
import com.airbnb.mvrx.args import com.airbnb.mvrx.args
import im.vector.app.R import im.vector.app.R
import im.vector.app.databinding.FragmentLoginWaitForEmailBinding import im.vector.app.core.utils.colorTerminatingFullStop
import im.vector.app.databinding.FragmentFtueWaitForEmailVerificationBinding
import im.vector.app.features.onboarding.OnboardingAction import im.vector.app.features.onboarding.OnboardingAction
import im.vector.app.features.onboarding.RegisterAction import im.vector.app.features.onboarding.RegisterAction
import im.vector.app.features.themes.ThemeProvider
import im.vector.app.features.themes.ThemeUtils
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.matrix.android.sdk.api.failure.is401
import javax.inject.Inject import javax.inject.Inject
@Parcelize @Parcelize
@ -38,45 +41,57 @@ data class FtueAuthWaitForEmailFragmentArgument(
/** /**
* In this screen, the user is asked to check their emails. * In this screen, the user is asked to check their emails.
*/ */
class FtueAuthWaitForEmailFragment @Inject constructor() : AbstractFtueAuthFragment<FragmentLoginWaitForEmailBinding>() { class FtueAuthWaitForEmailFragment @Inject constructor(
private val themeProvider: ThemeProvider
) : AbstractFtueAuthFragment<FragmentFtueWaitForEmailVerificationBinding>() {
private val params: FtueAuthWaitForEmailFragmentArgument by args() private val params: FtueAuthWaitForEmailFragmentArgument by args()
private var inferHasLeftAndReturnedToScreen = false
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentLoginWaitForEmailBinding { override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFtueWaitForEmailVerificationBinding {
return FragmentLoginWaitForEmailBinding.inflate(inflater, container, false) return FragmentFtueWaitForEmailVerificationBinding.inflate(inflater, container, false)
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
setupUi() setupUi()
} }
private fun setupUi() {
views.emailVerificationGradientContainer.setBackgroundResource(
when (themeProvider.isLightTheme()) {
true -> R.drawable.bg_waiting_for_email_verification
false -> R.drawable.bg_color_background
}
)
views.emailVerificationTitle.text = getString(R.string.ftue_auth_email_verification_title)
.colorTerminatingFullStop(ThemeUtils.getColor(requireContext(), R.attr.colorSecondary))
views.emailVerificationSubtitle.text = getString(R.string.ftue_auth_email_verification_subtitle, params.email)
views.emailVerificationResendEmail.debouncedClicks {
viewModel.handle(OnboardingAction.PostRegisterAction(RegisterAction.SendAgainThreePid))
}
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
showLoadingIfReturningToScreen()
viewModel.handle(OnboardingAction.PostRegisterAction(RegisterAction.CheckIfEmailHasBeenValidated(0))) viewModel.handle(OnboardingAction.PostRegisterAction(RegisterAction.CheckIfEmailHasBeenValidated(0)))
} }
private fun showLoadingIfReturningToScreen() {
when (inferHasLeftAndReturnedToScreen) {
true -> views.emailVerificationWaiting.isVisible = true
false -> {
inferHasLeftAndReturnedToScreen = true
}
}
}
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
viewModel.handle(OnboardingAction.StopEmailValidationCheck) 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.PostRegisterAction(RegisterAction.CheckIfEmailHasBeenValidated(10_000)))
} else {
super.onError(throwable)
}
}
override fun resetViewModel() { override fun resetViewModel() {
viewModel.handle(OnboardingAction.ResetAuthenticationAttempt) viewModel.handle(OnboardingAction.ResetAuthenticationAttempt)
} }

View File

@ -23,11 +23,11 @@ import im.vector.app.R
import im.vector.app.core.resources.LocaleProvider import im.vector.app.core.resources.LocaleProvider
import im.vector.app.core.resources.StringProvider import im.vector.app.core.resources.StringProvider
import im.vector.app.core.resources.isEnglishSpeaking import im.vector.app.core.resources.isEnglishSpeaking
import im.vector.app.core.utils.colorTerminatingFullStop
import im.vector.app.features.themes.ThemeProvider import im.vector.app.features.themes.ThemeProvider
import im.vector.app.features.themes.ThemeUtils import im.vector.app.features.themes.ThemeUtils
import im.vector.lib.core.utils.epoxy.charsequence.EpoxyCharSequence import im.vector.lib.core.utils.epoxy.charsequence.EpoxyCharSequence
import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence
import me.gujun.android.span.span
import javax.inject.Inject import javax.inject.Inject
class SplashCarouselStateFactory @Inject constructor( class SplashCarouselStateFactory @Inject constructor(
@ -39,7 +39,7 @@ class SplashCarouselStateFactory @Inject constructor(
fun create(): SplashCarouselState { fun create(): SplashCarouselState {
val lightTheme = themeProvider.isLightTheme() val lightTheme = themeProvider.isLightTheme()
fun background(@DrawableRes lightDrawable: Int) = if (lightTheme) lightDrawable else R.drawable.bg_carousel_page_dark fun background(@DrawableRes lightDrawable: Int) = if (lightTheme) lightDrawable else R.drawable.bg_color_background
fun hero(@DrawableRes lightDrawable: Int, @DrawableRes darkDrawable: Int) = if (lightTheme) lightDrawable else darkDrawable fun hero(@DrawableRes lightDrawable: Int, @DrawableRes darkDrawable: Int) = if (lightTheme) lightDrawable else darkDrawable
return SplashCarouselState( return SplashCarouselState(
listOf( listOf(
@ -79,18 +79,8 @@ class SplashCarouselStateFactory @Inject constructor(
} }
private fun Int.colorTerminatingFullStop(@AttrRes color: Int): EpoxyCharSequence { private fun Int.colorTerminatingFullStop(@AttrRes color: Int): EpoxyCharSequence {
val string = stringProvider.getString(this) return stringProvider.getString(this)
val fullStop = "." .colorTerminatingFullStop(ThemeUtils.getColor(context, color))
val charSequence = if (string.endsWith(fullStop)) { .toEpoxyCharSequence()
span {
+string.removeSuffix(fullStop)
span(fullStop) {
textColor = ThemeUtils.getColor(context, color)
}
}
} else {
string
}
return charSequence.toEpoxyCharSequence()
} }
} }

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="71dp"
android:height="70dp"
android:viewportWidth="71"
android:viewportHeight="70">
<path
android:pathData="M16.261,23.576L34.905,42.161C35.545,42.799 36.581,42.799 37.221,42.161L55.773,23.667C55.92,24.084 56,24.533 56,25V46C56,48.209 54.209,50 52,50H20C17.791,50 16,48.209 16,46V25C16,24.498 16.092,24.018 16.261,23.576ZM18.582,21.258C19.023,21.091 19.501,21 20,21H52C52.533,21 53.042,21.104 53.508,21.294L36.063,38.684L18.582,21.258Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
</vector>

View File

@ -0,0 +1,131 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
style="@style/LoginFormScrollView"
android:layout_height="match_parent"
android:background="?android:colorBackground"
android:fillViewport="true"
android:paddingTop="0dp"
android:paddingBottom="0dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.constraintlayout.widget.Guideline
android:id="@+id/emailEntryGutterStart"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="vertical"
app:layout_constraintGuide_percent="@dimen/ftue_auth_gutter_start_percent" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/emailEntryGutterEnd"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="vertical"
app:layout_constraintGuide_percent="@dimen/ftue_auth_gutter_end_percent" />
<Space
android:id="@+id/headerSpacing"
android:layout_width="match_parent"
android:layout_height="52dp"
app:layout_constraintBottom_toTopOf="@id/emailEntryHeaderIcon"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/emailEntryHeaderIcon"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:adjustViewBounds="true"
android:background="@drawable/circle"
android:backgroundTint="?colorSecondary"
android:contentDescription="@null"
android:src="@drawable/ic_email"
app:layout_constraintBottom_toTopOf="@id/emailEntryHeaderTitle"
app:layout_constraintEnd_toEndOf="@id/emailEntryGutterEnd"
app:layout_constraintHeight_percent="0.12"
app:layout_constraintStart_toStartOf="@id/emailEntryGutterStart"
app:layout_constraintTop_toTopOf="parent"
app:tint="@color/palette_white" />
<TextView
android:id="@+id/emailEntryHeaderTitle"
style="@style/Widget.Vector.TextView.Title.Medium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center"
android:text="@string/ftue_auth_email_title"
android:textColor="?vctr_content_primary"
app:layout_constraintBottom_toTopOf="@id/emailEntryHeaderSubtitle"
app:layout_constraintEnd_toEndOf="@id/emailEntryGutterEnd"
app:layout_constraintStart_toStartOf="@id/emailEntryGutterStart"
app:layout_constraintTop_toBottomOf="@id/emailEntryHeaderIcon" />
<TextView
android:id="@+id/emailEntryHeaderSubtitle"
style="@style/Widget.Vector.TextView.Subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:text="@string/ftue_auth_email_subtitle"
android:textColor="?vctr_content_secondary"
app:layout_constraintBottom_toTopOf="@id/titleContentSpacing"
app:layout_constraintEnd_toEndOf="@id/emailEntryGutterEnd"
app:layout_constraintStart_toStartOf="@id/emailEntryGutterStart"
app:layout_constraintTop_toBottomOf="@id/emailEntryHeaderTitle" />
<Space
android:id="@+id/titleContentSpacing"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/emailEntryInput"
app:layout_constraintHeight_percent="0.03"
app:layout_constraintTop_toBottomOf="@id/emailEntryHeaderSubtitle" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/emailEntryInput"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/ftue_auth_email_entry_title"
app:endIconMode="clear_text"
app:layout_constraintEnd_toEndOf="@id/emailEntryGutterEnd"
app:layout_constraintStart_toStartOf="@id/emailEntryGutterStart"
app:layout_constraintTop_toBottomOf="@id/titleContentSpacing">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="match_parent"
android:imeOptions="actionDone"
android:inputType="textEmailAddress"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<Space
android:id="@+id/entrySpacing"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/emailEntrySubmit"
app:layout_constraintHeight_percent="0.03"
app:layout_constraintTop_toBottomOf="@id/emailEntryInput"
app:layout_constraintVertical_bias="0"
app:layout_constraintVertical_chainStyle="packed" />
<Button
android:id="@+id/emailEntrySubmit"
style="@style/Widget.Vector.Button.Login"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/login_set_email_submit"
android:textAllCaps="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@id/emailEntryGutterEnd"
app:layout_constraintStart_toStartOf="@id/emailEntryGutterStart"
app:layout_constraintTop_toBottomOf="@id/entrySpacing" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>

View File

@ -0,0 +1,142 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.Guideline
android:id="@+id/ftueAuthGutterStart"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="vertical"
app:layout_constraintGuide_percent="@dimen/ftue_auth_gutter_start_percent" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/ftueAuthGutterEnd"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="vertical"
app:layout_constraintGuide_percent="@dimen/ftue_auth_gutter_end_percent" />
<View
android:id="@+id/emailVerificationGradientContainer"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintHeight_percent="0.60"
app:layout_constraintTop_toTopOf="parent"
tools:background="@drawable/bg_waiting_for_email_verification" />
<Space
android:id="@+id/emailVerificationSpace1"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/emailVerificationLogo"
app:layout_constraintHeight_percent="0.10"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="spread_inside" />
<ImageView
android:id="@+id/emailVerificationLogo"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:adjustViewBounds="true"
android:background="@drawable/circle"
android:backgroundTint="?colorSecondary"
android:importantForAccessibility="no"
android:src="@drawable/ic_email"
app:layout_constraintBottom_toTopOf="@id/emailVerificationSpace2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_percent="0.12"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/emailVerificationSpace1" />
<Space
android:id="@+id/emailVerificationSpace2"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/emailVerificationTitle"
app:layout_constraintHeight_percent="0.05"
app:layout_constraintTop_toBottomOf="@id/emailVerificationLogo" />
<TextView
android:id="@+id/emailVerificationTitle"
style="@style/Widget.Vector.TextView.Title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="center"
android:transitionName="loginTitleTransition"
app:layout_constraintBottom_toTopOf="@id/emailVerificationSubtitle"
app:layout_constraintEnd_toEndOf="@id/ftueAuthGutterEnd"
app:layout_constraintStart_toStartOf="@id/ftueAuthGutterStart"
app:layout_constraintTop_toBottomOf="@id/emailVerificationSpace2"
tools:text="@string/ftue_auth_email_verification_title" />
<TextView
android:id="@+id/emailVerificationSubtitle"
style="@style/Widget.Vector.TextView.Subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
app:layout_constraintBottom_toTopOf="@id/emailVerificationSpace4"
app:layout_constraintEnd_toEndOf="@id/ftueAuthGutterEnd"
app:layout_constraintStart_toStartOf="@id/ftueAuthGutterStart"
app:layout_constraintTop_toBottomOf="@id/emailVerificationTitle"
tools:text="@string/ftue_auth_email_verification_subtitle" />
<Space
android:id="@+id/emailVerificationSpace4"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/emailVerificationResendEmail"
app:layout_constraintTop_toBottomOf="@id/emailVerificationSubtitle" />
<ProgressBar
android:id="@+id/emailVerificationWaiting"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/emailVerificationSpace4"
app:layout_constraintEnd_toEndOf="@id/ftueAuthGutterEnd"
app:layout_constraintStart_toStartOf="@id/ftueAuthGutterStart"
app:layout_constraintTop_toTopOf="@id/emailVerificationSpace4"
tools:visibility="visible" />
<TextView
android:id="@+id/emailVerificationFooter"
style="@style/Widget.Vector.TextView.Subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:text="@string/ftue_auth_email_verification_footer"
app:layout_constraintBottom_toTopOf="@id/emailVerificationResendEmail"
app:layout_constraintEnd_toEndOf="@id/ftueAuthGutterEnd"
app:layout_constraintStart_toStartOf="@id/ftueAuthGutterStart"
app:layout_constraintTop_toBottomOf="@id/emailVerificationSpace4" />
<Button
android:id="@+id/emailVerificationResendEmail"
style="@style/Widget.Vector.Button.Text.Login"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:backgroundTint="@color/element_background_light"
android:text="@string/ftue_auth_email_resend_email"
android:textAllCaps="true"
android:textColor="?colorSecondary"
android:transitionName="loginSubmitTransition"
app:layout_constraintBottom_toTopOf="@id/emailVerificationSpace5"
app:layout_constraintEnd_toEndOf="@id/ftueAuthGutterEnd"
app:layout_constraintStart_toStartOf="@id/ftueAuthGutterStart"
app:layout_constraintTop_toBottomOf="@id/emailVerificationFooter" />
<Space
android:id="@+id/emailVerificationSpace5"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintHeight_percent="0.05"
app:layout_constraintTop_toBottomOf="@id/emailVerificationResendEmail" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -31,4 +31,13 @@
<string name="ftue_auth_terms_title">Privacy policy</string> <string name="ftue_auth_terms_title">Privacy policy</string>
<string name="ftue_auth_terms_subtitle">Please read through T&amp;C. You must accept in order to continue.</string> <string name="ftue_auth_terms_subtitle">Please read through T&amp;C. You must accept in order to continue.</string>
<string name="ftue_auth_email_title">Enter your email address</string>
<string name="ftue_auth_email_subtitle">This will help verify your account and enables password recovery.</string>
<string name="ftue_auth_email_entry_title">Email Address</string>
<string name="ftue_auth_email_verification_title">Check your email to verify.</string>
<!-- Note for translators, %s is the users email address -->
<string name="ftue_auth_email_verification_subtitle">To confirm your email address, tap the button in the email we just sent to %s</string>
<string name="ftue_auth_email_verification_footer">Did not receive an email?</string>
<string name="ftue_auth_email_resend_email">Resend email</string>
</resources> </resources>

View File

@ -46,8 +46,6 @@ import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
import org.matrix.android.sdk.api.auth.registration.FlowResult import org.matrix.android.sdk.api.auth.registration.FlowResult
import org.matrix.android.sdk.api.auth.registration.RegisterThreePid
import org.matrix.android.sdk.api.auth.registration.RegistrationResult
import org.matrix.android.sdk.api.auth.registration.Stage import org.matrix.android.sdk.api.auth.registration.Stage
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities
@ -57,10 +55,10 @@ private const val A_PICTURE_FILENAME = "a-picture.png"
private val AN_ERROR = RuntimeException("an error!") private val AN_ERROR = RuntimeException("an error!")
private val A_LOADABLE_REGISTER_ACTION = RegisterAction.StartRegistration private val A_LOADABLE_REGISTER_ACTION = RegisterAction.StartRegistration
private val A_NON_LOADABLE_REGISTER_ACTION = RegisterAction.CheckIfEmailHasBeenValidated(delayMillis = -1L) private val A_NON_LOADABLE_REGISTER_ACTION = RegisterAction.CheckIfEmailHasBeenValidated(delayMillis = -1L)
private val A_RESULT_IGNORED_REGISTER_ACTION = RegisterAction.AddThreePid(RegisterThreePid.Email("an email")) private val A_RESULT_IGNORED_REGISTER_ACTION = RegisterAction.SendAgainThreePid
private val A_HOMESERVER_CAPABILITIES = aHomeServerCapabilities(canChangeDisplayName = true, canChangeAvatar = true) private val A_HOMESERVER_CAPABILITIES = aHomeServerCapabilities(canChangeDisplayName = true, canChangeAvatar = true)
private val AN_IGNORED_FLOW_RESULT = FlowResult(missingStages = emptyList(), completedStages = emptyList()) private val AN_IGNORED_FLOW_RESULT = FlowResult(missingStages = emptyList(), completedStages = emptyList())
private val ANY_CONTINUING_REGISTRATION_RESULT = RegistrationResult.FlowResponse(AN_IGNORED_FLOW_RESULT) private val ANY_CONTINUING_REGISTRATION_RESULT = RegistrationResult.NextStep(AN_IGNORED_FLOW_RESULT)
private val A_LOGIN_OR_REGISTER_ACTION = OnboardingAction.LoginOrRegister("@a-user:id.org", "a-password", "a-device-name") private val A_LOGIN_OR_REGISTER_ACTION = OnboardingAction.LoginOrRegister("@a-user:id.org", "a-password", "a-device-name")
private const val A_HOMESERVER_URL = "https://edited-homeserver.org" private const val A_HOMESERVER_URL = "https://edited-homeserver.org"
private val A_HOMESERVER_CONFIG = HomeServerConnectionConfig(FakeUri().instance) private val A_HOMESERVER_CONFIG = HomeServerConnectionConfig(FakeUri().instance)
@ -230,7 +228,7 @@ class OnboardingViewModelTest {
@Test @Test
fun `given register action ignores result, when handling action, then does nothing on success`() = runTest { fun `given register action ignores result, when handling action, then does nothing on success`() = runTest {
val test = viewModel.test() val test = viewModel.test()
givenRegistrationResultFor(A_RESULT_IGNORED_REGISTER_ACTION, RegistrationResult.FlowResponse(AN_IGNORED_FLOW_RESULT)) givenRegistrationResultFor(A_RESULT_IGNORED_REGISTER_ACTION, RegistrationResult.NextStep(AN_IGNORED_FLOW_RESULT))
viewModel.handle(OnboardingAction.PostRegisterAction(A_RESULT_IGNORED_REGISTER_ACTION)) viewModel.handle(OnboardingAction.PostRegisterAction(A_RESULT_IGNORED_REGISTER_ACTION))
@ -249,7 +247,7 @@ class OnboardingViewModelTest {
viewModelWith(initialState.copy(onboardingFlow = OnboardingFlow.SignUp)) viewModelWith(initialState.copy(onboardingFlow = OnboardingFlow.SignUp))
fakeHomeServerConnectionConfigFactory.givenConfigFor(A_HOMESERVER_URL, A_HOMESERVER_CONFIG) fakeHomeServerConnectionConfigFactory.givenConfigFor(A_HOMESERVER_URL, A_HOMESERVER_CONFIG)
fakeStartAuthenticationFlowUseCase.givenResult(A_HOMESERVER_CONFIG, StartAuthenticationResult(isHomeserverOutdated = false, SELECTED_HOMESERVER_STATE)) fakeStartAuthenticationFlowUseCase.givenResult(A_HOMESERVER_CONFIG, StartAuthenticationResult(isHomeserverOutdated = false, SELECTED_HOMESERVER_STATE))
givenRegistrationResultFor(RegisterAction.StartRegistration, RegistrationResult.FlowResponse(AN_IGNORED_FLOW_RESULT)) givenRegistrationResultFor(RegisterAction.StartRegistration, RegistrationResult.NextStep(AN_IGNORED_FLOW_RESULT))
fakeHomeServerHistoryService.expectUrlToBeAdded(A_HOMESERVER_CONFIG.homeServerUri.toString()) fakeHomeServerHistoryService.expectUrlToBeAdded(A_HOMESERVER_CONFIG.homeServerUri.toString())
val test = viewModel.test() val test = viewModel.test()
@ -291,7 +289,7 @@ class OnboardingViewModelTest {
@Test @Test
fun `given personalisation enabled, when registering account, then updates state and emits account created event`() = runTest { fun `given personalisation enabled, when registering account, then updates state and emits account created event`() = runTest {
fakeVectorFeatures.givenPersonalisationEnabled() fakeVectorFeatures.givenPersonalisationEnabled()
givenRegistrationResultFor(A_LOADABLE_REGISTER_ACTION, RegistrationResult.Success(fakeSession)) givenRegistrationResultFor(A_LOADABLE_REGISTER_ACTION, RegistrationResult.Complete(fakeSession))
givenSuccessfullyCreatesAccount(A_HOMESERVER_CAPABILITIES) givenSuccessfullyCreatesAccount(A_HOMESERVER_CAPABILITIES)
val test = viewModel.test() val test = viewModel.test()
@ -495,8 +493,8 @@ class OnboardingViewModelTest {
val flowResult = FlowResult(missingStages = missingStages, completedStages = emptyList()) val flowResult = FlowResult(missingStages = missingStages, completedStages = emptyList())
givenRegistrationResultsFor( givenRegistrationResultsFor(
listOf( listOf(
A_LOADABLE_REGISTER_ACTION to RegistrationResult.FlowResponse(flowResult), A_LOADABLE_REGISTER_ACTION to RegistrationResult.NextStep(flowResult),
RegisterAction.RegisterDummy to RegistrationResult.Success(fakeSession) RegisterAction.RegisterDummy to RegistrationResult.Complete(fakeSession)
) )
) )
givenSuccessfullyCreatesAccount(A_HOMESERVER_CAPABILITIES) givenSuccessfullyCreatesAccount(A_HOMESERVER_CAPABILITIES)

View File

@ -18,16 +18,19 @@ package im.vector.app.features.onboarding
import im.vector.app.test.fakes.FakeRegistrationWizard import im.vector.app.test.fakes.FakeRegistrationWizard
import im.vector.app.test.fakes.FakeSession import im.vector.app.test.fakes.FakeSession
import im.vector.app.test.fixtures.a401ServerError
import io.mockk.coVerifyAll import io.mockk.coVerifyAll
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test import org.junit.Test
import org.matrix.android.sdk.api.auth.registration.RegisterThreePid import org.matrix.android.sdk.api.auth.registration.RegisterThreePid
import org.matrix.android.sdk.api.auth.registration.RegistrationResult
import org.matrix.android.sdk.api.auth.registration.RegistrationWizard import org.matrix.android.sdk.api.auth.registration.RegistrationWizard
import org.matrix.android.sdk.api.auth.registration.RegistrationResult as SdkResult
private const val IGNORED_DELAY = 0L
private val AN_ERROR = RuntimeException()
private val A_SESSION = FakeSession() private val A_SESSION = FakeSession()
private val AN_EXPECTED_RESULT = RegistrationResult.Success(A_SESSION) private val AN_EXPECTED_RESULT = RegistrationResult.Complete(A_SESSION)
private const val A_USERNAME = "a username" private const val A_USERNAME = "a username"
private const val A_PASSWORD = "a password" private const val A_PASSWORD = "a password"
private const val AN_INITIAL_DEVICE_NAME = "a device name" private const val AN_INITIAL_DEVICE_NAME = "a device name"
@ -38,6 +41,9 @@ private val A_PID_TO_REGISTER = RegisterThreePid.Email("an email")
class RegistrationActionHandlerTest { class RegistrationActionHandlerTest {
private val fakeRegistrationWizard = FakeRegistrationWizard()
private val registrationActionHandler = RegistrationActionHandler()
@Test @Test
fun `when handling register action then delegates to wizard`() = runTest { fun `when handling register action then delegates to wizard`() = runTest {
val cases = listOf( val cases = listOf(
@ -57,9 +63,52 @@ class RegistrationActionHandlerTest {
cases.forEach { testSuccessfulActionDelegation(it) } cases.forEach { testSuccessfulActionDelegation(it) }
} }
@Test
fun `given adding an email ThreePid fails with 401, when handling register action, then infer EmailSuccess`() = runTest {
fakeRegistrationWizard.givenAddEmailThreePidErrors(
cause = a401ServerError(),
email = A_PID_TO_REGISTER.email
)
val result = registrationActionHandler.handleRegisterAction(fakeRegistrationWizard, RegisterAction.AddThreePid(A_PID_TO_REGISTER))
result shouldBeEqualTo RegistrationResult.SendEmailSuccess(A_PID_TO_REGISTER.email)
}
@Test
fun `given email verification errors with 401 then fatal error, when checking email validation, then continues to poll until non 401 error`() = runTest {
val errorsToThrow = listOf(
a401ServerError(),
a401ServerError(),
a401ServerError(),
AN_ERROR
)
fakeRegistrationWizard.givenCheckIfEmailHasBeenValidatedErrors(errorsToThrow)
val result = registrationActionHandler.handleRegisterAction(fakeRegistrationWizard, RegisterAction.CheckIfEmailHasBeenValidated(IGNORED_DELAY))
fakeRegistrationWizard.verifyCheckedEmailedVerification(times = errorsToThrow.size)
result shouldBeEqualTo RegistrationResult.Error(AN_ERROR)
}
@Test
fun `given email verification errors with 401 and succeeds, when checking email validation, then continues to poll until success`() = runTest {
val errorsToThrow = listOf(
a401ServerError(),
a401ServerError(),
a401ServerError()
)
fakeRegistrationWizard.givenCheckIfEmailHasBeenValidatedErrors(errorsToThrow, finally = SdkResult.Success(A_SESSION))
val result = registrationActionHandler.handleRegisterAction(fakeRegistrationWizard, RegisterAction.CheckIfEmailHasBeenValidated(IGNORED_DELAY))
fakeRegistrationWizard.verifyCheckedEmailedVerification(times = errorsToThrow.size + 1)
result shouldBeEqualTo RegistrationResult.Complete(A_SESSION)
}
private suspend fun testSuccessfulActionDelegation(case: Case) { private suspend fun testSuccessfulActionDelegation(case: Case) {
val registrationActionHandler = RegistrationActionHandler()
val fakeRegistrationWizard = FakeRegistrationWizard() val fakeRegistrationWizard = FakeRegistrationWizard()
val registrationActionHandler = RegistrationActionHandler()
fakeRegistrationWizard.givenSuccessFor(result = A_SESSION, case.expect) fakeRegistrationWizard.givenSuccessFor(result = A_SESSION, case.expect)
val result = registrationActionHandler.handleRegisterAction(fakeRegistrationWizard, case.action) val result = registrationActionHandler.handleRegisterAction(fakeRegistrationWizard, case.action)
@ -69,6 +118,6 @@ class RegistrationActionHandlerTest {
} }
} }
private fun case(action: RegisterAction, expect: suspend RegistrationWizard.() -> RegistrationResult) = Case(action, expect) private fun case(action: RegisterAction, expect: suspend RegistrationWizard.() -> SdkResult) = Case(action, expect)
private class Case(val action: RegisterAction, val expect: suspend RegistrationWizard.() -> RegistrationResult) private class Case(val action: RegisterAction, val expect: suspend RegistrationWizard.() -> SdkResult)

View File

@ -18,9 +18,9 @@ package im.vector.app.test.fakes
import im.vector.app.features.onboarding.RegisterAction import im.vector.app.features.onboarding.RegisterAction
import im.vector.app.features.onboarding.RegistrationActionHandler import im.vector.app.features.onboarding.RegistrationActionHandler
import im.vector.app.features.onboarding.RegistrationResult
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.mockk import io.mockk.mockk
import org.matrix.android.sdk.api.auth.registration.RegistrationResult
import org.matrix.android.sdk.api.auth.registration.RegistrationWizard import org.matrix.android.sdk.api.auth.registration.RegistrationWizard
class FakeRegisterActionHandler { class FakeRegisterActionHandler {

View File

@ -17,7 +17,9 @@
package im.vector.app.test.fakes package im.vector.app.test.fakes
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk import io.mockk.mockk
import org.matrix.android.sdk.api.auth.registration.RegisterThreePid
import org.matrix.android.sdk.api.auth.registration.RegistrationResult import org.matrix.android.sdk.api.auth.registration.RegistrationResult
import org.matrix.android.sdk.api.auth.registration.RegistrationWizard import org.matrix.android.sdk.api.auth.registration.RegistrationWizard
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
@ -27,4 +29,21 @@ class FakeRegistrationWizard : RegistrationWizard by mockk(relaxed = false) {
fun givenSuccessFor(result: Session, expect: suspend RegistrationWizard.() -> RegistrationResult) { fun givenSuccessFor(result: Session, expect: suspend RegistrationWizard.() -> RegistrationResult) {
coEvery { expect(this@FakeRegistrationWizard) } returns RegistrationResult.Success(result) coEvery { expect(this@FakeRegistrationWizard) } returns RegistrationResult.Success(result)
} }
fun givenAddEmailThreePidErrors(cause: Throwable, email: String) {
coEvery { addThreePid(RegisterThreePid.Email(email)) } throws cause
}
fun givenCheckIfEmailHasBeenValidatedErrors(errors: List<Throwable>, finally: RegistrationResult? = null) {
var index = 0
coEvery { checkIfEmailHasBeenValidated(any()) } answers {
val current = index
index++
errors.getOrNull(current)?.let { throw it } ?: finally ?: throw RuntimeException("Developer error")
}
}
fun verifyCheckedEmailedVerification(times: Int) {
coVerify(exactly = times) { checkIfEmailHasBeenValidated(any()) }
}
} }

View File

@ -0,0 +1,25 @@
/*
* Copyright (c) 2022 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.test.fixtures
import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.failure.MatrixError
import javax.net.ssl.HttpsURLConnection
fun a401ServerError() = Failure.ServerError(
MatrixError(MatrixError.M_UNAUTHORIZED, ""), HttpsURLConnection.HTTP_UNAUTHORIZED
)