Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FTUE] - Email input and verification #5868

Merged
merged 27 commits into from
May 23, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d4a5b71
adding email input FTUE screen
ouchadam Apr 26, 2022
074e5bc
porting registration email verification polling to the registration a…
ouchadam Apr 27, 2022
b2d8163
adding unit test around polling for email verification
ouchadam Apr 27, 2022
02b6916
adding UI for updated email verification waiting screen
ouchadam Apr 27, 2022
4964c9f
showing loading spinner when returning to the email verification wait…
ouchadam Apr 27, 2022
817d692
renaming xml ids to the email verification domain and attaching the c…
ouchadam Apr 27, 2022
eb4d31e
extracting reusable logic for styling terminating full stops and appl…
ouchadam Apr 28, 2022
8a53eaf
adding gradient background to the waiting for verification screen, ma…
ouchadam Apr 28, 2022
9cc6467
removing copied back behaviour, isn't needed for the email entry screen
ouchadam Apr 28, 2022
350643c
resetting authentication state when the viewmodel resets whilst in th…
ouchadam Apr 28, 2022
8e7ae5e
removing extra end of file lines
ouchadam Apr 28, 2022
735adf0
adding changelog entry
ouchadam Apr 28, 2022
c0efd9f
updating ignored result register action as the one being used is now …
ouchadam Apr 28, 2022
8136f57
making use of the view lifecycle scope for the view based fragment logic
ouchadam May 5, 2022
a4b5d18
renaming sdk model to matrix
ouchadam May 5, 2022
c4834a4
aligning the carousel listener removal with the viewLifecycleOwner
ouchadam May 5, 2022
c414f80
adding listener suffix for consistency
ouchadam May 12, 2022
80b6b77
reusing editText unboxing extension
ouchadam May 12, 2022
641c06f
removing this usage for project consistency
ouchadam May 12, 2022
4dc8d23
removing unneeded state reacting when entering email address for veri…
ouchadam May 12, 2022
0979d56
inlining single use extension function
ouchadam May 12, 2022
bc5ebb2
adding gradient background to xml preview
ouchadam May 12, 2022
47635aa
avoiding cancelling the polling job when resending verification email
ouchadam May 12, 2022
9fddd09
using direct string reference for design preview
ouchadam May 12, 2022
4bcdaa3
removing unused imports
ouchadam May 13, 2022
2378643
adding missing punctuation
ouchadam May 20, 2022
c71f9c8
provides a dedicated job for the email verification polling to allow …
ouchadam May 20, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/5278.wip
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Adds email input and verification screens to the new FTUE onboarding flow
Original file line number Diff line number Diff line change
@@ -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>
12 changes: 12 additions & 0 deletions vector/src/main/java/im/vector/app/core/di/FragmentModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.FtueAuthChooseDisplayNameFragment
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.FtueAuthLegacyStyleCaptchaFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthLegacyWaitForEmailFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthLoginFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthPersonalizationCompleteFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthResetPasswordFragment
Expand Down Expand Up @@ -474,6 +476,11 @@ interface FragmentModule {
@FragmentKey(FtueAuthWaitForEmailFragment::class)
fun bindFtueAuthWaitForEmailFragment(fragment: FtueAuthWaitForEmailFragment): Fragment

@Binds
@IntoMap
@FragmentKey(FtueAuthLegacyWaitForEmailFragment::class)
fun bindFtueAuthLegacyWaitForEmailFragment(fragment: FtueAuthLegacyWaitForEmailFragment): Fragment

@Binds
@IntoMap
@FragmentKey(FtueAuthWebFragment::class)
Expand All @@ -494,6 +501,11 @@ interface FragmentModule {
@FragmentKey(FtueAuthAccountCreatedFragment::class)
fun bindFtueAuthAccountCreatedFragment(fragment: FtueAuthAccountCreatedFragment): Fragment

@Binds
@IntoMap
@FragmentKey(FtueAuthEmailEntryFragment::class)
fun bindFtueAuthEmailEntryFragment(fragment: FtueAuthEmailEntryFragment): Fragment
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe one day #5420 will be implemented...


@Binds
@IntoMap
@FragmentKey(FtueAuthChooseDisplayNameFragment::class)
Expand Down
33 changes: 33 additions & 0 deletions vector/src/main/java/im/vector/app/core/extensions/Job.kt
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@

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 im.vector.app.core.platform.SimpleTextWatcher
import kotlinx.coroutines.flow.map
import reactivecircus.flowbinding.android.widget.textChanges

Expand All @@ -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.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
}
}
}
15 changes: 15 additions & 0 deletions vector/src/main/java/im/vector/app/core/utils/SpannableUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import androidx.annotation.ColorInt
import me.gujun.android.span.Span
import me.gujun.android.span.span

fun Spannable.styleMatchingText(match: String, typeFace: Int): Spannable {
if (match.isEmpty()) return this
Expand Down Expand Up @@ -56,3 +57,17 @@ fun Span.bullet(text: CharSequence = "",
build()
})
}

fun String.colorTerminatingFullStop(@ColorInt color: Int): CharSequence {
val fullStop = "."
return if (endsWith(fullStop)) {
span {
[email protected](fullStop)
span(fullStop) {
textColor = color
}
}
} else {
this
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import im.vector.app.R
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.di.MavericksAssistedViewModelFactory
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.vectorStore
import im.vector.app.core.platform.VectorViewModel
Expand All @@ -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.login.LoginWizard
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.Stage
import org.matrix.android.sdk.api.session.Session
Expand Down Expand Up @@ -125,12 +125,8 @@ class OnboardingViewModel @AssistedInject constructor(

private var loginConfig: LoginConfig? = null

private var currentJob: Job? = null
set(value) {
// Cancel any previous Job
field?.cancel()
field = value
}
private var emailVerificationPollingJob: Job? by cancelCurrentOnSet()
private var currentJob: Job? by cancelCurrentOnSet()

override fun handle(action: OnboardingAction) {
when (action) {
Expand Down Expand Up @@ -257,13 +253,19 @@ class OnboardingViewModel @AssistedInject constructor(
}

private fun handleRegisterAction(action: RegisterAction, onNextRegistrationStepAction: (FlowResult) -> Unit) {
currentJob = viewModelScope.launch {
val job = viewModelScope.launch {
if (action.hasLoadingState()) {
setState { copy(isLoading = true) }
}
internalRegisterAction(action, onNextRegistrationStepAction)
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) {
Expand All @@ -275,8 +277,10 @@ class OnboardingViewModel @AssistedInject constructor(
// do nothing
}
else -> when (it) {
is RegistrationResult.Success -> onSessionCreated(it.session, isAccountCreated = true)
is RegistrationResult.FlowResponse -> onFlowResponse(it.flowResult, onNextRegistrationStepAction)
is RegistrationResult.Complete -> onSessionCreated(it.session, isAccountCreated = true)
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))
}
}
},
Expand Down Expand Up @@ -307,6 +311,7 @@ class OnboardingViewModel @AssistedInject constructor(
private fun handleResetAction(action: OnboardingAction.ResetAction) {
// Cancel any request
currentJob = null
emailVerificationPollingJob = null

when (action) {
OnboardingAction.ResetHomeServerType -> {
Expand Down Expand Up @@ -790,7 +795,7 @@ class OnboardingViewModel @AssistedInject constructor(
}

private fun cancelWaitForEmailValidation() {
currentJob = null
emailVerificationPollingJob = null
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,80 @@

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.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.failure.is401
import org.matrix.android.sdk.api.session.Session
import javax.inject.Inject
import org.matrix.android.sdk.api.auth.registration.RegistrationResult as MatrixRegistrationResult

class RegistrationActionHandler @Inject constructor() {

suspend fun handleRegisterAction(registrationWizard: RegistrationWizard, action: RegisterAction): RegistrationResult {
return when (action) {
RegisterAction.StartRegistration -> registrationWizard.getRegistrationFlow()
is RegisterAction.CaptchaDone -> registrationWizard.performReCaptcha(action.captchaResponse)
is RegisterAction.AcceptTerms -> registrationWizard.acceptTerms()
is RegisterAction.RegisterDummy -> registrationWizard.dummy()
is RegisterAction.AddThreePid -> registrationWizard.addThreePid(action.threePid)
is RegisterAction.SendAgainThreePid -> registrationWizard.sendAgainThreePid()
is RegisterAction.ValidateThreePid -> registrationWizard.handleValidateThreePid(action.code)
is RegisterAction.CheckIfEmailHasBeenValidated -> registrationWizard.checkIfEmailHasBeenValidated(action.delayMillis)
is RegisterAction.CreateAccount -> registrationWizard.createAccount(action.username, action.password, action.initialDeviceName)
RegisterAction.StartRegistration -> resultOf { registrationWizard.getRegistrationFlow() }
is RegisterAction.CaptchaDone -> resultOf { registrationWizard.performReCaptcha(action.captchaResponse) }
is RegisterAction.AcceptTerms -> resultOf { registrationWizard.acceptTerms() }
is RegisterAction.RegisterDummy -> resultOf { registrationWizard.dummy() }
is RegisterAction.AddThreePid -> handleAddThreePid(registrationWizard, action)
is RegisterAction.SendAgainThreePid -> resultOf { registrationWizard.sendAgainThreePid() }
is RegisterAction.ValidateThreePid -> resultOf { registrationWizard.handleValidateThreePid(action.code) }
is RegisterAction.CheckIfEmailHasBeenValidated -> handleCheckIfEmailIsValidated(registrationWizard, action.delayMillis)
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)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the 401 == successful pid sent logic is no longer handled at the UI/fragment layer but instead at our business logic layer

This logic feels like something that should be part of the SDK

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree!

else -> RegistrationResult.Error(it)
}
}
)
}

private tailrec suspend fun handleCheckIfEmailIsValidated(registrationWizard: RegistrationWizard, delayMillis: Long): RegistrationResult {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uses tailrec to provide recursion with the safety of avoiding stackoverflows, the kotlin compiler will optimise this to an imperative loop

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)
Copy link
Contributor Author

@ouchadam ouchadam Apr 28, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

previously we were providing the verification polling through the entire fragment -> viewmodel -> sdk stack, now it only happens here, the start/stop api remains the same

}
}

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 {
Expand All @@ -56,7 +110,6 @@ sealed interface RegisterAction {
}

fun RegisterAction.ignoresResult() = when (this) {
is RegisterAction.AddThreePid -> true
is RegisterAction.SendAgainThreePid -> true
else -> false
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
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.databinding.FragmentFtueDisplayNameBinding
import im.vector.app.features.onboarding.OnboardingAction
Expand Down Expand Up @@ -69,7 +69,7 @@ class FtueAuthChooseDisplayNameFragment @Inject constructor() : AbstractFtueAuth

override fun updateWithState(state: OnboardingViewState) {
views.displayNameInput.editText?.setText(state.personalizationState.displayName)
views.displayNameSubmit.isEnabled = views.displayNameInput.hasContentEmpty()
views.displayNameSubmit.isEnabled = views.displayNameInput.hasContent()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, the previous name was quite weird :)

}

override fun resetViewModel() {
Expand All @@ -81,5 +81,3 @@ class FtueAuthChooseDisplayNameFragment @Inject constructor() : AbstractFtueAuth
return true
}
}

private fun TextInputLayout.hasContentEmpty() = !editText?.text.isNullOrEmpty()
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ class FtueAuthCombinedServerSelectionFragment @Inject constructor() : AbstractFt
views.chooseServerSubmit.debouncedClicks { updateServerUrl() }
views.chooseServerInput.editText().textChanges()
.onEach { views.chooseServerInput.error = null }
.launchIn(lifecycleScope)
.launchIn(viewLifecycleOwner.lifecycleScope)
}

private fun updateServerUrl() {
Expand Down
Loading