Merge pull request #6108 from vector-im/feature/adm/ftue-msisdn-entry

FTUE - Msisdn (phone number) entry
This commit is contained in:
Adam Brown 2022-07-01 12:07:57 +01:00 committed by GitHub
commit e326188aa8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 446 additions and 21 deletions

View file

@ -110,6 +110,7 @@ import im.vector.app.features.onboarding.ftueauth.FtueAuthLegacyStyleCaptchaFrag
import im.vector.app.features.onboarding.ftueauth.FtueAuthLegacyWaitForEmailFragment 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.FtueAuthPhoneEntryFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthResetPasswordFragment import im.vector.app.features.onboarding.ftueauth.FtueAuthResetPasswordFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthResetPasswordMailConfirmationFragment import im.vector.app.features.onboarding.ftueauth.FtueAuthResetPasswordMailConfirmationFragment
import im.vector.app.features.onboarding.ftueauth.FtueAuthResetPasswordSuccessFragment import im.vector.app.features.onboarding.ftueauth.FtueAuthResetPasswordSuccessFragment
@ -509,6 +510,11 @@ interface FragmentModule {
@FragmentKey(FtueAuthEmailEntryFragment::class) @FragmentKey(FtueAuthEmailEntryFragment::class)
fun bindFtueAuthEmailEntryFragment(fragment: FtueAuthEmailEntryFragment): Fragment fun bindFtueAuthEmailEntryFragment(fragment: FtueAuthEmailEntryFragment): Fragment
@Binds
@IntoMap
@FragmentKey(FtueAuthPhoneEntryFragment::class)
fun bindFtueAuthPhoneEntryFragment(fragment: FtueAuthPhoneEntryFragment): Fragment
@Binds @Binds
@IntoMap @IntoMap
@FragmentKey(FtueAuthChooseDisplayNameFragment::class) @FragmentKey(FtueAuthChooseDisplayNameFragment::class)

View file

@ -21,6 +21,7 @@ import android.content.Context
import android.content.Context.MODE_PRIVATE import android.content.Context.MODE_PRIVATE
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.res.Resources import android.content.res.Resources
import com.google.i18n.phonenumbers.PhoneNumberUtil
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@ -193,6 +194,9 @@ object VectorStaticModule {
return analyticsConfig return analyticsConfig
} }
@Provides
fun providesPhoneNumberUtil(): PhoneNumberUtil = PhoneNumberUtil.getInstance()
@Provides @Provides
@Singleton @Singleton
fun providesBuildMeta() = BuildMeta() fun providesBuildMeta() = BuildMeta()

View file

@ -16,9 +16,11 @@
package im.vector.app.core.extensions package im.vector.app.core.extensions
import android.os.Build
import android.text.Editable import android.text.Editable
import android.view.View import android.view.View
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import androidx.autofill.HintConstants
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.textfield.TextInputLayout import com.google.android.material.textfield.TextInputLayout
@ -79,3 +81,12 @@ fun TextInputLayout.setOnFocusLostListener(action: () -> Unit) {
} }
} }
} }
fun TextInputLayout.autofillPhoneNumber() = setAutofillHint(HintConstants.AUTOFILL_HINT_PHONE_NUMBER)
fun TextInputLayout.autofillEmail() = setAutofillHint(HintConstants.AUTOFILL_HINT_EMAIL_ADDRESS)
private fun TextInputLayout.setAutofillHint(hintType: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
setAutofillHints(hintType)
}
}

View file

@ -305,6 +305,7 @@ class OnboardingViewModel @AssistedInject constructor(
RegistrationActionHandler.Result.StartRegistration -> _viewEvents.post(OnboardingViewEvents.DisplayStartRegistration) RegistrationActionHandler.Result.StartRegistration -> _viewEvents.post(OnboardingViewEvents.DisplayStartRegistration)
RegistrationActionHandler.Result.UnsupportedStage -> _viewEvents.post(OnboardingViewEvents.DisplayRegistrationFallback) RegistrationActionHandler.Result.UnsupportedStage -> _viewEvents.post(OnboardingViewEvents.DisplayRegistrationFallback)
is RegistrationActionHandler.Result.SendEmailSuccess -> _viewEvents.post(OnboardingViewEvents.OnSendEmailSuccess(it.email)) is RegistrationActionHandler.Result.SendEmailSuccess -> _viewEvents.post(OnboardingViewEvents.OnSendEmailSuccess(it.email))
is RegistrationActionHandler.Result.SendMsisdnSuccess -> _viewEvents.post(OnboardingViewEvents.OnSendMsisdnSuccess(it.msisdn.msisdn))
is RegistrationActionHandler.Result.Error -> _viewEvents.post(OnboardingViewEvents.Failure(it.cause)) is RegistrationActionHandler.Result.Error -> _viewEvents.post(OnboardingViewEvents.Failure(it.cause))
RegistrationActionHandler.Result.MissingNextStage -> { RegistrationActionHandler.Result.MissingNextStage -> {
_viewEvents.post(OnboardingViewEvents.Failure(IllegalStateException("No next registration stage found"))) _viewEvents.post(OnboardingViewEvents.Failure(IllegalStateException("No next registration stage found")))

View file

@ -26,6 +26,7 @@ import im.vector.app.features.onboarding.ftueauth.MatrixOrgRegistrationStagesCom
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import org.matrix.android.sdk.api.auth.AuthenticationService import org.matrix.android.sdk.api.auth.AuthenticationService
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.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 javax.inject.Inject import javax.inject.Inject
@ -47,7 +48,8 @@ class RegistrationActionHandler @Inject constructor(
else -> when (result) { else -> when (result) {
is RegistrationResult.Complete -> Result.RegistrationComplete(result.session) is RegistrationResult.Complete -> Result.RegistrationComplete(result.session)
is RegistrationResult.NextStep -> processFlowResult(result, state) is RegistrationResult.NextStep -> processFlowResult(result, state)
is RegistrationResult.SendEmailSuccess -> Result.SendEmailSuccess(result.email) is RegistrationResult.SendEmailSuccess -> Result.SendEmailSuccess(result.email.email)
is RegistrationResult.SendMsisdnSuccess -> Result.SendMsisdnSuccess(result.msisdn)
is RegistrationResult.Error -> Result.Error(result.cause) is RegistrationResult.Error -> Result.Error(result.cause)
} }
} }
@ -95,6 +97,7 @@ class RegistrationActionHandler @Inject constructor(
data class NextStage(val stage: Stage) : Result data class NextStage(val stage: Stage) : Result
data class Error(val cause: Throwable) : Result data class Error(val cause: Throwable) : Result
data class SendEmailSuccess(val email: String) : Result data class SendEmailSuccess(val email: String) : Result
data class SendMsisdnSuccess(val msisdn: RegisterThreePid.Msisdn) : Result
object MissingNextStage : Result object MissingNextStage : Result
object StartRegistration : Result object StartRegistration : Result
object UnsupportedStage : Result object UnsupportedStage : Result

View file

@ -59,7 +59,8 @@ class RegistrationWizardActionDelegate @Inject constructor(
onSuccess = { it.toRegistrationResult() }, onSuccess = { it.toRegistrationResult() },
onFailure = { onFailure = {
when { when {
action.threePid is RegisterThreePid.Email && it.is401() -> RegistrationResult.SendEmailSuccess(action.threePid.email) action.threePid is RegisterThreePid.Email && it.is401() -> RegistrationResult.SendEmailSuccess(action.threePid)
action.threePid is RegisterThreePid.Msisdn && it.is401() -> RegistrationResult.SendMsisdnSuccess(action.threePid)
else -> RegistrationResult.Error(it) else -> RegistrationResult.Error(it)
} }
} }
@ -95,7 +96,8 @@ sealed interface RegistrationResult {
data class Error(val cause: Throwable) : RegistrationResult data class Error(val cause: Throwable) : RegistrationResult
data class Complete(val session: Session) : RegistrationResult data class Complete(val session: Session) : RegistrationResult
data class NextStep(val flowResult: FlowResult) : RegistrationResult data class NextStep(val flowResult: FlowResult) : RegistrationResult
data class SendEmailSuccess(val email: String) : RegistrationResult data class SendEmailSuccess(val email: RegisterThreePid.Email) : RegistrationResult
data class SendMsisdnSuccess(val msisdn: RegisterThreePid.Msisdn) : RegistrationResult
} }
sealed interface RegisterAction { sealed interface RegisterAction {

View file

@ -21,6 +21,7 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import im.vector.app.core.extensions.associateContentStateWith import im.vector.app.core.extensions.associateContentStateWith
import im.vector.app.core.extensions.autofillEmail
import im.vector.app.core.extensions.clearErrorOnChange import im.vector.app.core.extensions.clearErrorOnChange
import im.vector.app.core.extensions.content import im.vector.app.core.extensions.content
import im.vector.app.core.extensions.isEmail import im.vector.app.core.extensions.isEmail
@ -47,6 +48,7 @@ class FtueAuthEmailEntryFragment @Inject constructor() : AbstractFtueAuthFragmen
views.emailEntryInput.setOnImeDoneListener { updateEmail() } views.emailEntryInput.setOnImeDoneListener { updateEmail() }
views.emailEntryInput.clearErrorOnChange(viewLifecycleOwner) views.emailEntryInput.clearErrorOnChange(viewLifecycleOwner)
views.emailEntrySubmit.debouncedClicks { updateEmail() } views.emailEntrySubmit.debouncedClicks { updateEmail() }
views.emailEntryInput.autofillEmail()
} }
private fun updateEmail() { private fun updateEmail() {

View file

@ -36,7 +36,6 @@ import im.vector.app.core.extensions.setTextOrHide
import im.vector.app.databinding.FragmentLoginGenericTextInputFormBinding import im.vector.app.databinding.FragmentLoginGenericTextInputFormBinding
import im.vector.app.features.login.TextInputFormFragmentMode import im.vector.app.features.login.TextInputFormFragmentMode
import im.vector.app.features.onboarding.OnboardingAction import im.vector.app.features.onboarding.OnboardingAction
import im.vector.app.features.onboarding.OnboardingViewEvents
import im.vector.app.features.onboarding.RegisterAction import im.vector.app.features.onboarding.RegisterAction
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -226,12 +225,7 @@ class FtueAuthGenericTextInputFormFragment @Inject constructor() : AbstractFtueA
views.loginGenericTextInputFormTil.error = errorFormatter.toHumanReadable(throwable) views.loginGenericTextInputFormTil.error = errorFormatter.toHumanReadable(throwable)
} }
TextInputFormFragmentMode.SetMsisdn -> { TextInputFormFragmentMode.SetMsisdn -> {
if (throwable.is401()) { views.loginGenericTextInputFormTil.error = errorFormatter.toHumanReadable(throwable)
// 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 -> { TextInputFormFragmentMode.ConfirmMsisdn -> {
when { when {

View file

@ -0,0 +1,87 @@
/*
* 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.R
import im.vector.app.core.extensions.associateContentStateWith
import im.vector.app.core.extensions.autofillPhoneNumber
import im.vector.app.core.extensions.content
import im.vector.app.core.extensions.editText
import im.vector.app.core.extensions.setOnImeDoneListener
import im.vector.app.databinding.FragmentFtuePhoneInputBinding
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 FtueAuthPhoneEntryFragment @Inject constructor(
private val phoneNumberParser: PhoneNumberParser
) : AbstractFtueAuthFragment<FragmentFtuePhoneInputBinding>() {
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFtuePhoneInputBinding {
return FragmentFtuePhoneInputBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupViews()
}
private fun setupViews() {
views.phoneEntryInput.associateContentStateWith(button = views.phoneEntrySubmit)
views.phoneEntryInput.setOnImeDoneListener { updatePhoneNumber() }
views.phoneEntrySubmit.debouncedClicks { updatePhoneNumber() }
views.phoneEntryInput.editText().textChanges()
.onEach {
views.phoneEntryInput.error = null
views.phoneEntrySubmit.isEnabled = it.isNotBlank()
}
.launchIn(viewLifecycleOwner.lifecycleScope)
views.phoneEntryInput.autofillPhoneNumber()
}
private fun updatePhoneNumber() {
val number = views.phoneEntryInput.content()
when (val result = phoneNumberParser.parseInternationalNumber(number)) {
PhoneNumberParser.Result.ErrorInvalidNumber -> views.phoneEntryInput.error = getString(R.string.login_msisdn_error_other)
PhoneNumberParser.Result.ErrorMissingInternationalCode -> views.phoneEntryInput.error = getString(R.string.login_msisdn_error_not_international)
is PhoneNumberParser.Result.Success -> {
val (countryCode, phoneNumber) = result
viewModel.handle(OnboardingAction.PostRegisterAction(RegisterAction.AddThreePid(RegisterThreePid.Msisdn(phoneNumber, countryCode))))
}
}
}
override fun onError(throwable: Throwable) {
views.phoneEntryInput.error = errorFormatter.toHumanReadable(throwable)
}
override fun resetViewModel() {
viewModel.handle(OnboardingAction.ResetAuthenticationAttempt)
}
}

View file

@ -388,12 +388,21 @@ class FtueAuthVariant(
when (stage) { when (stage) {
is Stage.ReCaptcha -> onCaptcha(stage) is Stage.ReCaptcha -> onCaptcha(stage)
is Stage.Email -> onEmail(stage) is Stage.Email -> onEmail(stage)
is Stage.Msisdn -> addRegistrationStageFragmentToBackstack( is Stage.Msisdn -> onMsisdn(stage)
is Stage.Terms -> onTerms(stage)
else -> Unit // Should not happen
}
}
private fun onMsisdn(stage: Stage) {
when {
vectorFeatures.isOnboardingCombinedRegisterEnabled() -> addRegistrationStageFragmentToBackstack(
FtueAuthPhoneEntryFragment::class.java
)
else -> addRegistrationStageFragmentToBackstack(
FtueAuthGenericTextInputFormFragment::class.java, FtueAuthGenericTextInputFormFragment::class.java,
FtueAuthGenericTextInputFormFragmentArgument(TextInputFormFragmentMode.SetMsisdn, stage.mandatory), FtueAuthGenericTextInputFormFragmentArgument(TextInputFormFragmentMode.SetMsisdn, stage.mandatory),
) )
is Stage.Terms -> onTerms(stage)
else -> Unit // Should not happen
} }
} }

View file

@ -0,0 +1,48 @@
/*
* 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 com.google.i18n.phonenumbers.NumberParseException
import com.google.i18n.phonenumbers.PhoneNumberUtil
import javax.inject.Inject
class PhoneNumberParser @Inject constructor(
private val phoneNumberUtil: PhoneNumberUtil
) {
fun parseInternationalNumber(rawPhoneNumber: String): Result {
return when {
rawPhoneNumber.doesNotStartWith("+") -> Result.ErrorMissingInternationalCode
else -> parseNumber(rawPhoneNumber)
}
}
private fun parseNumber(rawPhoneNumber: String) = try {
val phoneNumber = phoneNumberUtil.parse(rawPhoneNumber, null)
Result.Success(phoneNumberUtil.getRegionCodeForCountryCode(phoneNumber.countryCode), rawPhoneNumber)
} catch (e: NumberParseException) {
Result.ErrorInvalidNumber
}
sealed interface Result {
object ErrorMissingInternationalCode : Result
object ErrorInvalidNumber : Result
data class Success(val countryCode: String, val phoneNumber: String) : Result
}
private fun String.doesNotStartWith(input: String) = !startsWith(input)
}

View file

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:width="71dp"
android:height="70dp"
android:viewportWidth="71"
android:viewportHeight="70">
<path
android:fillColor="#ff0000"
android:pathData="M29.378,41.602C31.28,43.655 35.858,47.21 37.106,47.94C37.18,47.983 37.264,48.033 37.359,48.09C39.263,49.221 45.229,52.768 49.576,49.449C52.944,46.877 51.848,43.985 50.715,43.125C49.939,42.521 47.652,40.857 45.501,39.359C43.389,37.887 42.211,39.066 41.415,39.863C41.401,39.878 41.386,39.892 41.372,39.907L39.77,41.508C39.362,41.916 38.742,41.767 38.148,41.301C36.015,39.677 34.447,38.11 33.662,37.325L33.655,37.318C32.871,36.534 31.323,34.984 29.699,32.852C29.233,32.258 29.084,31.638 29.492,31.23L31.093,29.628C31.108,29.614 31.122,29.599 31.137,29.584C31.934,28.788 33.113,27.611 31.641,25.499C30.143,23.347 28.479,21.061 27.875,20.285C27.015,19.151 24.122,18.056 21.551,21.424C18.232,25.771 21.778,31.737 22.91,33.641C22.966,33.736 23.017,33.82 23.06,33.894C23.789,35.142 27.325,39.7 29.378,41.602Z"
tools:ignore="VectorPath" />
</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/phoneEntryGutterStart"
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/phoneEntryGutterEnd"
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/phoneEntryHeaderIcon"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/phoneEntryHeaderIcon"
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_ftue_phone"
app:layout_constraintBottom_toTopOf="@id/phoneEntryHeaderTitle"
app:layout_constraintEnd_toEndOf="@id/phoneEntryGutterEnd"
app:layout_constraintHeight_percent="0.12"
app:layout_constraintStart_toStartOf="@id/phoneEntryGutterStart"
app:layout_constraintTop_toTopOf="parent"
app:tint="@color/palette_white" />
<TextView
android:id="@+id/phoneEntryHeaderTitle"
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_phone_title"
android:textColor="?vctr_content_primary"
app:layout_constraintBottom_toTopOf="@id/phoneEntryHeaderSubtitle"
app:layout_constraintEnd_toEndOf="@id/phoneEntryGutterEnd"
app:layout_constraintStart_toStartOf="@id/phoneEntryGutterStart"
app:layout_constraintTop_toBottomOf="@id/phoneEntryHeaderIcon" />
<TextView
android:id="@+id/phoneEntryHeaderSubtitle"
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_phone_subtitle"
android:textColor="?vctr_content_secondary"
app:layout_constraintBottom_toTopOf="@id/titleContentSpacing"
app:layout_constraintEnd_toEndOf="@id/phoneEntryGutterEnd"
app:layout_constraintStart_toStartOf="@id/phoneEntryGutterStart"
app:layout_constraintTop_toBottomOf="@id/phoneEntryHeaderTitle" />
<Space
android:id="@+id/titleContentSpacing"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/phoneEntryInput"
app:layout_constraintHeight_percent="0.03"
app:layout_constraintTop_toBottomOf="@id/phoneEntryHeaderSubtitle" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/phoneEntryInput"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/ftue_auth_phone_entry_title"
app:endIconMode="clear_text"
app:layout_constraintEnd_toEndOf="@id/phoneEntryGutterEnd"
app:layout_constraintStart_toStartOf="@id/phoneEntryGutterStart"
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="phone"
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/phoneEntrySubmit"
app:layout_constraintHeight_percent="0.03"
app:layout_constraintTop_toBottomOf="@id/phoneEntryInput"
app:layout_constraintVertical_bias="0"
app:layout_constraintVertical_chainStyle="packed" />
<Button
android:id="@+id/phoneEntrySubmit"
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/phoneEntryGutterEnd"
app:layout_constraintStart_toStartOf="@id/phoneEntryGutterStart"
app:layout_constraintTop_toBottomOf="@id/entrySpacing" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>

View file

@ -36,6 +36,10 @@
<string name="ftue_auth_email_title">Enter your email address</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_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_entry_title">Email Address</string>
<string name="ftue_auth_phone_title">Enter your phone number</string>
<string name="ftue_auth_phone_subtitle">This will help verify your account and enables password recovery.</string>
<string name="ftue_auth_phone_entry_title">Phone Number</string>
<string name="ftue_auth_reset_password_email_subtitle">We will send you a verification link.</string> <string name="ftue_auth_reset_password_email_subtitle">We will send you a verification link.</string>
<string name="ftue_auth_reset_password_breaker_title">Check your email.</string> <string name="ftue_auth_reset_password_breaker_title">Check your email.</string>
<string name="ftue_auth_new_password_entry_title">New Password</string> <string name="ftue_auth_new_password_entry_title">New Password</string>

View file

@ -38,7 +38,8 @@ private const val AN_INITIAL_DEVICE_NAME = "a device name"
private const val A_CAPTCHA_RESPONSE = "a captcha response" private const val A_CAPTCHA_RESPONSE = "a captcha response"
private const val A_PID_CODE = "a pid code" private const val A_PID_CODE = "a pid code"
private const val EMAIL_VALIDATED_DELAY = 10000L private const val EMAIL_VALIDATED_DELAY = 10000L
private val A_PID_TO_REGISTER = RegisterThreePid.Email("an email") private val AN_EMAIL_PID_TO_REGISTER = RegisterThreePid.Email("an email")
private val A_PHONE_PID_TO_REGISTER = RegisterThreePid.Msisdn("+11111111111", countryCode = "GB")
class RegistrationWizardActionDelegateTest { class RegistrationWizardActionDelegateTest {
@ -55,7 +56,7 @@ class RegistrationWizardActionDelegateTest {
case(RegisterAction.CaptchaDone(A_CAPTCHA_RESPONSE)) { performReCaptcha(A_CAPTCHA_RESPONSE) }, case(RegisterAction.CaptchaDone(A_CAPTCHA_RESPONSE)) { performReCaptcha(A_CAPTCHA_RESPONSE) },
case(RegisterAction.AcceptTerms) { acceptTerms() }, case(RegisterAction.AcceptTerms) { acceptTerms() },
case(RegisterAction.RegisterDummy) { dummy() }, case(RegisterAction.RegisterDummy) { dummy() },
case(RegisterAction.AddThreePid(A_PID_TO_REGISTER)) { addThreePid(A_PID_TO_REGISTER) }, case(RegisterAction.AddThreePid(AN_EMAIL_PID_TO_REGISTER)) { addThreePid(AN_EMAIL_PID_TO_REGISTER) },
case(RegisterAction.SendAgainThreePid) { sendAgainThreePid() }, case(RegisterAction.SendAgainThreePid) { sendAgainThreePid() },
case(RegisterAction.ValidateThreePid(A_PID_CODE)) { handleValidateThreePid(A_PID_CODE) }, case(RegisterAction.ValidateThreePid(A_PID_CODE)) { handleValidateThreePid(A_PID_CODE) },
case(RegisterAction.CheckIfEmailHasBeenValidated(EMAIL_VALIDATED_DELAY)) { checkIfEmailHasBeenValidated(EMAIL_VALIDATED_DELAY) }, case(RegisterAction.CheckIfEmailHasBeenValidated(EMAIL_VALIDATED_DELAY)) { checkIfEmailHasBeenValidated(EMAIL_VALIDATED_DELAY) },
@ -69,14 +70,14 @@ class RegistrationWizardActionDelegateTest {
@Test @Test
fun `given adding an email ThreePid fails with 401, when handling register action, then infer EmailSuccess`() = runTest { fun `given adding an email ThreePid fails with 401, when handling register action, then infer EmailSuccess`() = runTest {
fakeRegistrationWizard.givenAddEmailThreePidErrors( fakeRegistrationWizard.givenAddThreePidErrors(
cause = a401ServerError(), cause = a401ServerError(),
email = A_PID_TO_REGISTER.email threePid = AN_EMAIL_PID_TO_REGISTER
) )
val result = registrationActionHandler.executeAction(RegisterAction.AddThreePid(A_PID_TO_REGISTER)) val result = registrationActionHandler.executeAction(RegisterAction.AddThreePid(AN_EMAIL_PID_TO_REGISTER))
result shouldBeEqualTo RegistrationResult.SendEmailSuccess(A_PID_TO_REGISTER.email) result shouldBeEqualTo RegistrationResult.SendEmailSuccess(AN_EMAIL_PID_TO_REGISTER)
} }
@Test @Test
@ -110,6 +111,18 @@ class RegistrationWizardActionDelegateTest {
result shouldBeEqualTo RegistrationResult.Complete(A_SESSION) result shouldBeEqualTo RegistrationResult.Complete(A_SESSION)
} }
@Test
fun `given adding an Msisdn ThreePid fails with 401, when handling register action, then infer EmailSuccess`() = runTest {
fakeRegistrationWizard.givenAddThreePidErrors(
cause = a401ServerError(),
threePid = A_PHONE_PID_TO_REGISTER
)
val result = registrationActionHandler.executeAction(RegisterAction.AddThreePid(A_PHONE_PID_TO_REGISTER))
result shouldBeEqualTo RegistrationResult.SendMsisdnSuccess(A_PHONE_PID_TO_REGISTER)
}
private suspend fun testSuccessfulActionDelegation(case: Case) { private suspend fun testSuccessfulActionDelegation(case: Case) {
val fakeRegistrationWizard = FakeRegistrationWizard() val fakeRegistrationWizard = FakeRegistrationWizard()
val authenticationService = FakeAuthenticationService().also { it.givenRegistrationWizard(fakeRegistrationWizard) } val authenticationService = FakeAuthenticationService().also { it.givenRegistrationWizard(fakeRegistrationWizard) }

View file

@ -0,0 +1,59 @@
/*
* 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 im.vector.app.test.fakes.FakePhoneNumberUtil
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
private const val AN_INTERNATIONAL_PHONE_NUMBER = "+4411111111111"
private const val A_NON_INTERNATIONAL_PHONE_NUMBER = "111111111111"
private const val AN_INVALID_INTERNATIONAL_NUMBER = "+abc"
private const val A_COUNTRY_CODE = "GB"
private const val A_COUNTRY_CALLING_CODE = 44
class PhoneNumberParserTest {
private val fakePhoneNumberUtil = FakePhoneNumberUtil()
private val phoneNumberParser = PhoneNumberParser(fakePhoneNumberUtil.instance)
@Test
fun `given a calling code and country code are successfully read when parsing, then returns success with country code`() {
fakePhoneNumberUtil.givenCountryCallingCodeFor(AN_INTERNATIONAL_PHONE_NUMBER, callingCode = A_COUNTRY_CALLING_CODE)
fakePhoneNumberUtil.givenRegionCodeFor(A_COUNTRY_CALLING_CODE, countryCode = A_COUNTRY_CODE)
val result = phoneNumberParser.parseInternationalNumber(AN_INTERNATIONAL_PHONE_NUMBER)
result shouldBeEqualTo PhoneNumberParser.Result.Success(A_COUNTRY_CODE, AN_INTERNATIONAL_PHONE_NUMBER)
}
@Test
fun `given a non internation phone number, when parsing, then returns MissingInternationCode error`() {
val result = phoneNumberParser.parseInternationalNumber(A_NON_INTERNATIONAL_PHONE_NUMBER)
result shouldBeEqualTo PhoneNumberParser.Result.ErrorMissingInternationalCode
}
@Test
fun `given an invalid phone number, when parsing, then returns ErrorInvalidNumber error`() {
fakePhoneNumberUtil.givenFailsToParse(AN_INVALID_INTERNATIONAL_NUMBER)
val result = phoneNumberParser.parseInternationalNumber(AN_INVALID_INTERNATIONAL_NUMBER)
result shouldBeEqualTo PhoneNumberParser.Result.ErrorInvalidNumber
}
}

View file

@ -0,0 +1,40 @@
/*
* 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.fakes
import com.google.i18n.phonenumbers.NumberParseException
import com.google.i18n.phonenumbers.PhoneNumberUtil
import com.google.i18n.phonenumbers.Phonenumber
import io.mockk.every
import io.mockk.mockk
class FakePhoneNumberUtil {
val instance = mockk<PhoneNumberUtil>()
fun givenCountryCallingCodeFor(phoneNumber: String, callingCode: Int) {
every { instance.parse(phoneNumber, null) } returns Phonenumber.PhoneNumber().setCountryCode(callingCode)
}
fun givenRegionCodeFor(callingCode: Int, countryCode: String) {
every { instance.getRegionCodeForCountryCode(callingCode) } returns countryCode
}
fun givenFailsToParse(phoneNumber: String) {
every { instance.parse(phoneNumber, null) } throws NumberParseException(NumberParseException.ErrorType.NOT_A_NUMBER, "")
}
}

View file

@ -30,8 +30,8 @@ class FakeRegistrationWizard : RegistrationWizard by mockk(relaxed = false) {
coEvery { expect(this@FakeRegistrationWizard) } returns RegistrationResult.Success(result) coEvery { expect(this@FakeRegistrationWizard) } returns RegistrationResult.Success(result)
} }
fun givenAddEmailThreePidErrors(cause: Throwable, email: String) { fun givenAddThreePidErrors(cause: Throwable, threePid: RegisterThreePid) {
coEvery { addThreePid(RegisterThreePid.Email(email)) } throws cause coEvery { addThreePid(threePid) } throws cause
} }
fun givenCheckIfEmailHasBeenValidatedErrors(errors: List<Throwable>, finally: RegistrationResult? = null) { fun givenCheckIfEmailHasBeenValidatedErrors(errors: List<Throwable>, finally: RegistrationResult? = null) {