Merge pull request #6036 from vector-im/feature/adm/ftue-deeplinks

FTUE - Homeserver sign in/up deeplinks
This commit is contained in:
Adam Brown 2022-05-31 09:30:17 +01:00 committed by GitHub
commit 7fdf138e5a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 343 additions and 128 deletions

1
changelog.d/6023.wip Normal file
View file

@ -0,0 +1 @@
FTUE - Adds homeserver login/register deeplink support

View file

@ -33,6 +33,7 @@ import im.vector.app.config.analyticsConfig
import im.vector.app.core.dispatchers.CoroutineDispatchers import im.vector.app.core.dispatchers.CoroutineDispatchers
import im.vector.app.core.error.DefaultErrorFormatter import im.vector.app.core.error.DefaultErrorFormatter
import im.vector.app.core.error.ErrorFormatter import im.vector.app.core.error.ErrorFormatter
import im.vector.app.core.resources.BuildMeta
import im.vector.app.core.time.Clock import im.vector.app.core.time.Clock
import im.vector.app.core.time.DefaultClock import im.vector.app.core.time.DefaultClock
import im.vector.app.features.analytics.AnalyticsConfig import im.vector.app.features.analytics.AnalyticsConfig
@ -185,4 +186,8 @@ object VectorStaticModule {
fun providesAnalyticsConfig(): AnalyticsConfig { fun providesAnalyticsConfig(): AnalyticsConfig {
return analyticsConfig return analyticsConfig
} }
@Provides
@Singleton
fun providesBuildMeta() = BuildMeta()
} }

View file

@ -16,9 +16,13 @@
package im.vector.app.core.extensions package im.vector.app.core.extensions
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri import android.net.Uri
import android.os.Build
import android.text.Spannable import android.text.Spannable
import android.text.SpannableString import android.text.SpannableString
import android.text.style.ImageSpan import android.text.style.ImageSpan
@ -27,11 +31,13 @@ import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.FloatRange import androidx.annotation.FloatRange
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import dagger.hilt.EntryPoints import dagger.hilt.EntryPoints
import im.vector.app.core.datastore.dataStoreProvider import im.vector.app.core.datastore.dataStoreProvider
import im.vector.app.core.di.SingletonEntryPoint import im.vector.app.core.di.SingletonEntryPoint
import im.vector.app.core.resources.BuildMeta
import java.io.OutputStream import java.io.OutputStream
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -77,3 +83,31 @@ val Context.dataStoreProvider: (String) -> DataStore<Preferences> by dataStorePr
fun Context.safeOpenOutputStream(uri: Uri): OutputStream? { fun Context.safeOpenOutputStream(uri: Uri): OutputStream? {
return contentResolver.openOutputStream(uri, "wt") return contentResolver.openOutputStream(uri, "wt")
} }
/**
* Checks for an active connection to infer if the device is offline.
* This is useful for breaking down UnknownHost exceptions and should not be used to determine if a valid connection is present
*
* @return true if no active connection is found
*/
@Suppress("deprecation")
@SuppressLint("NewApi") // false positive
fun Context.inferNoConnectivity(buildMeta: BuildMeta): Boolean {
val connectivityManager = getSystemService<ConnectivityManager>()!!
return if (buildMeta.sdkInt > Build.VERSION_CODES.M) {
val networkCapabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
when {
networkCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true -> false
networkCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true -> false
networkCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true -> false
else -> true
}
} else {
when (connectivityManager.activeNetworkInfo?.type) {
ConnectivityManager.TYPE_WIFI -> false
ConnectivityManager.TYPE_MOBILE -> false
ConnectivityManager.TYPE_VPN -> false
else -> true
}
}
}

View file

@ -0,0 +1,23 @@
/*
* 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.resources
import android.os.Build
data class BuildMeta(
val sdkInt: Int = Build.VERSION.SDK_INT
)

View file

@ -25,8 +25,12 @@ import org.matrix.android.sdk.api.auth.data.Credentials
import org.matrix.android.sdk.api.network.ssl.Fingerprint import org.matrix.android.sdk.api.network.ssl.Fingerprint
sealed interface OnboardingAction : VectorViewModelAction { sealed interface OnboardingAction : VectorViewModelAction {
data class OnGetStarted(val resetLoginConfig: Boolean, val onboardingFlow: OnboardingFlow) : OnboardingAction sealed interface SplashAction : OnboardingAction {
data class OnIAlreadyHaveAnAccount(val resetLoginConfig: Boolean, val onboardingFlow: OnboardingFlow) : OnboardingAction val onboardingFlow: OnboardingFlow
data class OnGetStarted(override val onboardingFlow: OnboardingFlow) : SplashAction
data class OnIAlreadyHaveAnAccount(override val onboardingFlow: OnboardingFlow) : SplashAction
}
data class UpdateServerType(val serverType: ServerType) : OnboardingAction data class UpdateServerType(val serverType: ServerType) : OnboardingAction
@ -58,7 +62,7 @@ sealed interface OnboardingAction : VectorViewModelAction {
// Reset actions // Reset actions
sealed interface ResetAction : OnboardingAction sealed interface ResetAction : OnboardingAction
object ResetDeeplinkConfig : ResetAction
object ResetHomeServerType : ResetAction object ResetHomeServerType : ResetAction
object ResetHomeServerUrl : ResetAction object ResetHomeServerUrl : ResetAction
object ResetSignMode : ResetAction object ResetSignMode : ResetAction

View file

@ -28,6 +28,7 @@ import org.matrix.android.sdk.api.auth.registration.FlowResult
sealed class OnboardingViewEvents : VectorViewEvents { sealed class OnboardingViewEvents : VectorViewEvents {
data class Loading(val message: CharSequence? = null) : OnboardingViewEvents() data class Loading(val message: CharSequence? = null) : OnboardingViewEvents()
data class Failure(val throwable: Throwable) : OnboardingViewEvents() data class Failure(val throwable: Throwable) : OnboardingViewEvents()
data class DeeplinkAuthenticationFailure(val retryAction: OnboardingAction) : OnboardingViewEvents()
data class RegistrationFlowResult(val flowResult: FlowResult, val isRegistrationStarted: Boolean) : OnboardingViewEvents() data class RegistrationFlowResult(val flowResult: FlowResult, val isRegistrationStarted: Boolean) : OnboardingViewEvents()
object OutdatedHomeserver : OnboardingViewEvents() object OutdatedHomeserver : OnboardingViewEvents()

View file

@ -27,9 +27,12 @@ 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.cancelCurrentOnSet
import im.vector.app.core.extensions.configureAndStart import im.vector.app.core.extensions.configureAndStart
import im.vector.app.core.extensions.inferNoConnectivity
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
import im.vector.app.core.resources.BuildMeta
import im.vector.app.core.resources.StringProvider import im.vector.app.core.resources.StringProvider
import im.vector.app.core.utils.ensureProtocol
import im.vector.app.core.utils.ensureTrailingSlash import im.vector.app.core.utils.ensureTrailingSlash
import im.vector.app.features.VectorFeatures import im.vector.app.features.VectorFeatures
import im.vector.app.features.VectorOverrides import im.vector.app.features.VectorOverrides
@ -55,6 +58,7 @@ 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.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.failure.isHomeserverUnavailable
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
@ -78,7 +82,8 @@ class OnboardingViewModel @AssistedInject constructor(
private val registrationActionHandler: RegistrationActionHandler, private val registrationActionHandler: RegistrationActionHandler,
private val directLoginUseCase: DirectLoginUseCase, private val directLoginUseCase: DirectLoginUseCase,
private val startAuthenticationFlowUseCase: StartAuthenticationFlowUseCase, private val startAuthenticationFlowUseCase: StartAuthenticationFlowUseCase,
private val vectorOverrides: VectorOverrides private val vectorOverrides: VectorOverrides,
private val buildMeta: BuildMeta
) : VectorViewModel<OnboardingViewState, OnboardingAction, OnboardingViewEvents>(initialState) { ) : VectorViewModel<OnboardingViewState, OnboardingAction, OnboardingViewEvents>(initialState) {
@AssistedFactory @AssistedFactory
@ -132,8 +137,7 @@ class OnboardingViewModel @AssistedInject constructor(
override fun handle(action: OnboardingAction) { override fun handle(action: OnboardingAction) {
when (action) { when (action) {
is OnboardingAction.OnGetStarted -> handleSplashAction(action.resetLoginConfig, action.onboardingFlow) is OnboardingAction.SplashAction -> handleSplashAction(action)
is OnboardingAction.OnIAlreadyHaveAnAccount -> handleSplashAction(action.resetLoginConfig, action.onboardingFlow)
is OnboardingAction.UpdateUseCase -> handleUpdateUseCase(action) is OnboardingAction.UpdateUseCase -> handleUpdateUseCase(action)
OnboardingAction.ResetUseCase -> resetUseCase() OnboardingAction.ResetUseCase -> resetUseCase()
is OnboardingAction.UpdateServerType -> handleUpdateServerType(action) is OnboardingAction.UpdateServerType -> handleUpdateServerType(action)
@ -173,26 +177,9 @@ class OnboardingViewModel @AssistedInject constructor(
} }
} }
private fun handleSplashAction(resetConfig: Boolean, onboardingFlow: OnboardingFlow) { private fun handleSplashAction(action: OnboardingAction.SplashAction) {
if (resetConfig) { setState { copy(onboardingFlow = action.onboardingFlow) }
loginConfig = null continueToPageAfterSplash(action.onboardingFlow)
}
setState { copy(onboardingFlow = onboardingFlow) }
return when (val config = loginConfig.toHomeserverConfig()) {
null -> continueToPageAfterSplash(onboardingFlow)
else -> startAuthenticationFlow(trigger = null, config, ServerType.Other)
}
}
private fun LoginConfig?.toHomeserverConfig(): HomeServerConnectionConfig? {
return this?.homeServerUrl?.takeIf { it.isNotEmpty() }?.let { url ->
homeServerConnectionConfigFactory.create(url).also {
if (it == null) {
Timber.w("Url from config url was invalid: $url")
}
}
}
} }
private fun continueToPageAfterSplash(onboardingFlow: OnboardingFlow) { private fun continueToPageAfterSplash(onboardingFlow: OnboardingFlow) {
@ -206,10 +193,21 @@ class OnboardingViewModel @AssistedInject constructor(
} }
) )
} }
OnboardingFlow.SignIn -> if (vectorFeatures.isOnboardingCombinedLoginEnabled()) { OnboardingFlow.SignIn -> when {
handle(OnboardingAction.HomeServerChange.SelectHomeServer(defaultHomeserverUrl)) vectorFeatures.isOnboardingCombinedLoginEnabled() -> {
} else _viewEvents.post(OnboardingViewEvents.OpenServerSelection) handle(OnboardingAction.HomeServerChange.SelectHomeServer(deeplinkOrDefaultHomeserverUrl()))
OnboardingFlow.SignInSignUp -> _viewEvents.post(OnboardingViewEvents.OpenServerSelection) }
else -> openServerSelectionOrDeeplinkToOther()
}
OnboardingFlow.SignInSignUp -> openServerSelectionOrDeeplinkToOther()
}
}
private fun openServerSelectionOrDeeplinkToOther() {
when (loginConfig) {
null -> _viewEvents.post(OnboardingViewEvents.OpenServerSelection)
else -> handleHomeserverChange(OnboardingAction.HomeServerChange.SelectHomeServer(deeplinkOrDefaultHomeserverUrl()), ServerType.Other)
} }
} }
@ -220,7 +218,7 @@ class OnboardingViewModel @AssistedInject constructor(
is OnboardingAction.HomeServerChange.SelectHomeServer -> { is OnboardingAction.HomeServerChange.SelectHomeServer -> {
currentHomeServerConnectionConfig currentHomeServerConnectionConfig
?.let { it.copy(allowedFingerprints = it.allowedFingerprints + action.fingerprint) } ?.let { it.copy(allowedFingerprints = it.allowedFingerprints + action.fingerprint) }
?.let { startAuthenticationFlow(finalLastAction, it) } ?.let { startAuthenticationFlow(finalLastAction, it, serverTypeOverride = null) }
} }
is AuthenticateAction.LoginDirect -> is AuthenticateAction.LoginDirect ->
handleDirectLogin( handleDirectLogin(
@ -374,6 +372,7 @@ class OnboardingViewModel @AssistedInject constructor(
) )
} }
} }
OnboardingAction.ResetDeeplinkConfig -> loginConfig = null
} }
} }
@ -394,11 +393,13 @@ class OnboardingViewModel @AssistedInject constructor(
private fun handleUpdateUseCase(action: OnboardingAction.UpdateUseCase) { private fun handleUpdateUseCase(action: OnboardingAction.UpdateUseCase) {
setState { copy(useCase = action.useCase) } setState { copy(useCase = action.useCase) }
when (vectorFeatures.isOnboardingCombinedRegisterEnabled()) { when (vectorFeatures.isOnboardingCombinedRegisterEnabled()) {
true -> handle(OnboardingAction.HomeServerChange.SelectHomeServer(defaultHomeserverUrl)) true -> handle(OnboardingAction.HomeServerChange.SelectHomeServer(deeplinkOrDefaultHomeserverUrl()))
false -> _viewEvents.post(OnboardingViewEvents.OpenServerSelection) false -> _viewEvents.post(OnboardingViewEvents.OpenServerSelection)
} }
} }
private fun deeplinkOrDefaultHomeserverUrl() = loginConfig?.homeServerUrl?.ensureProtocol() ?: defaultHomeserverUrl
private fun resetUseCase() { private fun resetUseCase() {
setState { copy(useCase = null) } setState { copy(useCase = null) }
} }
@ -422,7 +423,6 @@ class OnboardingViewModel @AssistedInject constructor(
private fun handleInitWith(action: OnboardingAction.InitWith) { private fun handleInitWith(action: OnboardingAction.InitWith) {
loginConfig = action.loginConfig loginConfig = action.loginConfig
// If there is a pending email validation continue on this step // If there is a pending email validation continue on this step
try { try {
if (registrationWizard.isRegistrationStarted()) { if (registrationWizard.isRegistrationStarted()) {
@ -611,20 +611,20 @@ class OnboardingViewModel @AssistedInject constructor(
} }
} }
private fun handleHomeserverChange(action: OnboardingAction.HomeServerChange) { private fun handleHomeserverChange(action: OnboardingAction.HomeServerChange, serverTypeOverride: ServerType? = null) {
val homeServerConnectionConfig = homeServerConnectionConfigFactory.create(action.homeServerUrl) val homeServerConnectionConfig = homeServerConnectionConfigFactory.create(action.homeServerUrl)
if (homeServerConnectionConfig == null) { if (homeServerConnectionConfig == null) {
// This is invalid // This is invalid
_viewEvents.post(OnboardingViewEvents.Failure(Throwable("Unable to create a HomeServerConnectionConfig"))) _viewEvents.post(OnboardingViewEvents.Failure(Throwable("Unable to create a HomeServerConnectionConfig")))
} else { } else {
startAuthenticationFlow(action, homeServerConnectionConfig) startAuthenticationFlow(action, homeServerConnectionConfig, serverTypeOverride)
} }
} }
private fun startAuthenticationFlow( private fun startAuthenticationFlow(
trigger: OnboardingAction?, trigger: OnboardingAction.HomeServerChange,
homeServerConnectionConfig: HomeServerConnectionConfig, homeServerConnectionConfig: HomeServerConnectionConfig,
serverTypeOverride: ServerType? = null serverTypeOverride: ServerType?
) { ) {
currentHomeServerConnectionConfig = homeServerConnectionConfig currentHomeServerConnectionConfig = homeServerConnectionConfig
@ -632,14 +632,36 @@ class OnboardingViewModel @AssistedInject constructor(
setState { copy(isLoading = true) } setState { copy(isLoading = true) }
runCatching { startAuthenticationFlowUseCase.execute(homeServerConnectionConfig) }.fold( runCatching { startAuthenticationFlowUseCase.execute(homeServerConnectionConfig) }.fold(
onSuccess = { onAuthenticationStartedSuccess(trigger, homeServerConnectionConfig, it, serverTypeOverride) }, onSuccess = { onAuthenticationStartedSuccess(trigger, homeServerConnectionConfig, it, serverTypeOverride) },
onFailure = { _viewEvents.post(OnboardingViewEvents.Failure(it)) } onFailure = { onAuthenticationStartError(it, trigger) }
) )
setState { copy(isLoading = false) } setState { copy(isLoading = false) }
} }
} }
private fun onAuthenticationStartError(error: Throwable, trigger: OnboardingAction.HomeServerChange) {
when {
error.isHomeserverUnavailable() && applicationContext.inferNoConnectivity(buildMeta) -> _viewEvents.post(
OnboardingViewEvents.Failure(error)
)
deeplinkUrlIsUnavailable(error, trigger) -> _viewEvents.post(
OnboardingViewEvents.DeeplinkAuthenticationFailure(
retryAction = (trigger as OnboardingAction.HomeServerChange.SelectHomeServer).resetToDefaultUrl()
)
)
else -> _viewEvents.post(
OnboardingViewEvents.Failure(error)
)
}
}
private fun deeplinkUrlIsUnavailable(error: Throwable, trigger: OnboardingAction.HomeServerChange) = error.isHomeserverUnavailable() &&
loginConfig != null &&
trigger is OnboardingAction.HomeServerChange.SelectHomeServer
private fun OnboardingAction.HomeServerChange.SelectHomeServer.resetToDefaultUrl() = copy(homeServerUrl = defaultHomeserverUrl)
private suspend fun onAuthenticationStartedSuccess( private suspend fun onAuthenticationStartedSuccess(
trigger: OnboardingAction?, trigger: OnboardingAction.HomeServerChange,
config: HomeServerConnectionConfig, config: HomeServerConnectionConfig,
authResult: StartAuthenticationResult, authResult: StartAuthenticationResult,
serverTypeOverride: ServerType? serverTypeOverride: ServerType?
@ -650,47 +672,51 @@ class OnboardingViewModel @AssistedInject constructor(
} }
when (trigger) { when (trigger) {
is OnboardingAction.HomeServerChange.EditHomeServer -> {
when (awaitState().onboardingFlow) {
OnboardingFlow.SignUp -> internalRegisterAction(RegisterAction.StartRegistration) {
updateServerSelection(config, serverTypeOverride, authResult)
_viewEvents.post(OnboardingViewEvents.OnHomeserverEdited)
}
OnboardingFlow.SignIn -> {
updateServerSelection(config, serverTypeOverride, authResult)
_viewEvents.post(OnboardingViewEvents.OnHomeserverEdited)
}
else -> throw IllegalArgumentException("developer error")
}
}
is OnboardingAction.HomeServerChange.SelectHomeServer -> { is OnboardingAction.HomeServerChange.SelectHomeServer -> {
updateServerSelection(config, serverTypeOverride, authResult) onHomeServerSelected(config, serverTypeOverride, authResult)
if (authResult.selectedHomeserver.preferredLoginMode.supportsSignModeScreen()) { }
when (awaitState().onboardingFlow) { is OnboardingAction.HomeServerChange.EditHomeServer -> {
OnboardingFlow.SignIn -> { onHomeServerEdited(config, serverTypeOverride, authResult)
updateSignMode(SignMode.SignIn) }
when (vectorFeatures.isOnboardingCombinedLoginEnabled()) { }
true -> _viewEvents.post(OnboardingViewEvents.OpenCombinedLogin) }
false -> _viewEvents.post(OnboardingViewEvents.OnSignModeSelected(SignMode.SignIn))
} private suspend fun onHomeServerSelected(config: HomeServerConnectionConfig, serverTypeOverride: ServerType?, authResult: StartAuthenticationResult) {
} updateServerSelection(config, serverTypeOverride, authResult)
OnboardingFlow.SignUp -> { if (authResult.selectedHomeserver.preferredLoginMode.supportsSignModeScreen()) {
updateSignMode(SignMode.SignUp) when (awaitState().onboardingFlow) {
internalRegisterAction(RegisterAction.StartRegistration, ::emitFlowResultViewEvent) OnboardingFlow.SignIn -> {
} updateSignMode(SignMode.SignIn)
OnboardingFlow.SignInSignUp, when (vectorFeatures.isOnboardingCombinedLoginEnabled()) {
null -> { true -> _viewEvents.post(OnboardingViewEvents.OpenCombinedLogin)
_viewEvents.post(OnboardingViewEvents.OnLoginFlowRetrieved) false -> _viewEvents.post(OnboardingViewEvents.OnSignModeSelected(SignMode.SignIn))
}
} }
} else { }
OnboardingFlow.SignUp -> {
updateSignMode(SignMode.SignUp)
internalRegisterAction(RegisterAction.StartRegistration, ::emitFlowResultViewEvent)
}
OnboardingFlow.SignInSignUp,
null -> {
_viewEvents.post(OnboardingViewEvents.OnLoginFlowRetrieved) _viewEvents.post(OnboardingViewEvents.OnLoginFlowRetrieved)
} }
} }
else -> { } else {
_viewEvents.post(OnboardingViewEvents.OnLoginFlowRetrieved)
}
}
private suspend fun onHomeServerEdited(config: HomeServerConnectionConfig, serverTypeOverride: ServerType?, authResult: StartAuthenticationResult) {
when (awaitState().onboardingFlow) {
OnboardingFlow.SignUp -> internalRegisterAction(RegisterAction.StartRegistration) {
updateServerSelection(config, serverTypeOverride, authResult) updateServerSelection(config, serverTypeOverride, authResult)
_viewEvents.post(OnboardingViewEvents.OnLoginFlowRetrieved) _viewEvents.post(OnboardingViewEvents.OnHomeserverEdited)
} }
OnboardingFlow.SignIn -> {
updateServerSelection(config, serverTypeOverride, authResult)
_viewEvents.post(OnboardingViewEvents.OnHomeserverEdited)
}
else -> throw IllegalArgumentException("developer error")
} }
} }

View file

@ -26,8 +26,6 @@ import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner 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.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import im.vector.app.BuildConfig import im.vector.app.BuildConfig
import im.vector.app.R import im.vector.app.R
@ -41,8 +39,6 @@ import im.vector.app.features.settings.VectorPreferences
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.failure.Failure
import java.net.UnknownHostException
import javax.inject.Inject import javax.inject.Inject
private const val CAROUSEL_ROTATION_DELAY_MS = 5000L private const val CAROUSEL_ROTATION_DELAY_MS = 5000L
@ -128,33 +124,14 @@ class FtueAuthSplashCarouselFragment @Inject constructor(
private fun splashSubmit(isAlreadyHaveAccountEnabled: Boolean) { private fun splashSubmit(isAlreadyHaveAccountEnabled: Boolean) {
val getStartedFlow = if (isAlreadyHaveAccountEnabled) OnboardingFlow.SignUp else OnboardingFlow.SignInSignUp val getStartedFlow = if (isAlreadyHaveAccountEnabled) OnboardingFlow.SignUp else OnboardingFlow.SignInSignUp
viewModel.handle(OnboardingAction.OnGetStarted(resetLoginConfig = false, onboardingFlow = getStartedFlow)) viewModel.handle(OnboardingAction.SplashAction.OnGetStarted(onboardingFlow = getStartedFlow))
} }
private fun alreadyHaveAnAccount() { private fun alreadyHaveAnAccount() {
viewModel.handle(OnboardingAction.OnIAlreadyHaveAnAccount(resetLoginConfig = false, onboardingFlow = OnboardingFlow.SignIn)) viewModel.handle(OnboardingAction.SplashAction.OnIAlreadyHaveAnAccount(onboardingFlow = OnboardingFlow.SignIn))
} }
override fun resetViewModel() { override fun resetViewModel() {
// Nothing to do // Nothing to do
} }
override fun onError(throwable: Throwable) {
if (throwable is Failure.NetworkConnection &&
throwable.ioException is UnknownHostException) {
// Invalid homeserver from URL config
val url = viewModel.getInitialHomeServerUrl().orEmpty()
MaterialAlertDialogBuilder(requireActivity())
.setTitle(R.string.dialog_title_error)
.setMessage(getString(R.string.login_error_homeserver_from_url_not_found, url))
.setPositiveButton(R.string.login_error_homeserver_from_url_not_found_enter_manual) { _, _ ->
val flow = withState(viewModel) { it.onboardingFlow } ?: OnboardingFlow.SignInSignUp
viewModel.handle(OnboardingAction.OnGetStarted(resetLoginConfig = true, flow))
}
.setNegativeButton(R.string.action_cancel, null)
.show()
} else {
super.onError(throwable)
}
}
} }

View file

@ -22,8 +22,6 @@ 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 com.airbnb.mvrx.withState
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import im.vector.app.BuildConfig import im.vector.app.BuildConfig
import im.vector.app.R import im.vector.app.R
import im.vector.app.databinding.FragmentFtueAuthSplashBinding import im.vector.app.databinding.FragmentFtueAuthSplashBinding
@ -31,8 +29,6 @@ import im.vector.app.features.VectorFeatures
import im.vector.app.features.onboarding.OnboardingAction import im.vector.app.features.onboarding.OnboardingAction
import im.vector.app.features.onboarding.OnboardingFlow import im.vector.app.features.onboarding.OnboardingFlow
import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorPreferences
import org.matrix.android.sdk.api.failure.Failure
import java.net.UnknownHostException
import javax.inject.Inject import javax.inject.Inject
/** /**
@ -75,33 +71,14 @@ class FtueAuthSplashFragment @Inject constructor(
private fun splashSubmit(isAlreadyHaveAccountEnabled: Boolean) { private fun splashSubmit(isAlreadyHaveAccountEnabled: Boolean) {
val getStartedFlow = if (isAlreadyHaveAccountEnabled) OnboardingFlow.SignUp else OnboardingFlow.SignInSignUp val getStartedFlow = if (isAlreadyHaveAccountEnabled) OnboardingFlow.SignUp else OnboardingFlow.SignInSignUp
viewModel.handle(OnboardingAction.OnGetStarted(resetLoginConfig = false, onboardingFlow = getStartedFlow)) viewModel.handle(OnboardingAction.SplashAction.OnGetStarted(onboardingFlow = getStartedFlow))
} }
private fun alreadyHaveAnAccount() { private fun alreadyHaveAnAccount() {
viewModel.handle(OnboardingAction.OnIAlreadyHaveAnAccount(resetLoginConfig = false, onboardingFlow = OnboardingFlow.SignIn)) viewModel.handle(OnboardingAction.SplashAction.OnIAlreadyHaveAnAccount(onboardingFlow = OnboardingFlow.SignIn))
} }
override fun resetViewModel() { override fun resetViewModel() {
// Nothing to do // Nothing to do
} }
override fun onError(throwable: Throwable) {
if (throwable is Failure.NetworkConnection &&
throwable.ioException is UnknownHostException) {
// Invalid homeserver from URL config
val url = viewModel.getInitialHomeServerUrl().orEmpty()
MaterialAlertDialogBuilder(requireActivity())
.setTitle(R.string.dialog_title_error)
.setMessage(getString(R.string.login_error_homeserver_from_url_not_found, url))
.setPositiveButton(R.string.login_error_homeserver_from_url_not_found_enter_manual) { _, _ ->
val flow = withState(viewModel) { it.onboardingFlow } ?: OnboardingFlow.SignInSignUp
viewModel.handle(OnboardingAction.OnGetStarted(resetLoginConfig = true, flow))
}
.setNegativeButton(R.string.action_cancel, null)
.show()
} else {
super.onError(throwable)
}
}
} }

View file

@ -44,7 +44,7 @@ private const val DARK_MODE_ICON_BACKGROUND_ALPHA = 0.30f
private const val LIGHT_MODE_ICON_BACKGROUND_ALPHA = 0.15f private const val LIGHT_MODE_ICON_BACKGROUND_ALPHA = 0.15f
class FtueAuthUseCaseFragment @Inject constructor( class FtueAuthUseCaseFragment @Inject constructor(
private val themeProvider: ThemeProvider private val themeProvider: ThemeProvider,
) : AbstractFtueAuthFragment<FragmentFtueAuthUseCaseBinding>() { ) : AbstractFtueAuthFragment<FragmentFtueAuthUseCaseBinding>() {
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFtueAuthUseCaseBinding { override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentFtueAuthUseCaseBinding {
@ -104,7 +104,7 @@ class FtueAuthUseCaseFragment @Inject constructor(
private fun createIcon(@ColorRes tint: Int, icon: Int, isLightMode: Boolean): Drawable { private fun createIcon(@ColorRes tint: Int, icon: Int, isLightMode: Boolean): Drawable {
val context = requireContext() val context = requireContext()
val alpha = when (isLightMode) { val alpha = when (isLightMode) {
true -> LIGHT_MODE_ICON_BACKGROUND_ALPHA true -> LIGHT_MODE_ICON_BACKGROUND_ALPHA
false -> DARK_MODE_ICON_BACKGROUND_ALPHA false -> DARK_MODE_ICON_BACKGROUND_ALPHA
} }
val iconBackground = context.getResTintedDrawable(R.drawable.bg_feature_icon, tint, alpha = alpha) val iconBackground = context.getResTintedDrawable(R.drawable.bg_feature_icon, tint, alpha = alpha)

View file

@ -227,11 +227,28 @@ class FtueAuthVariant(
option = commonOption option = commonOption
) )
} }
OnboardingViewEvents.OnHomeserverEdited -> activity.popBackstack() OnboardingViewEvents.OnHomeserverEdited -> activity.popBackstack()
OnboardingViewEvents.OpenCombinedLogin -> onStartCombinedLogin() OnboardingViewEvents.OpenCombinedLogin -> onStartCombinedLogin()
is OnboardingViewEvents.DeeplinkAuthenticationFailure -> onDeeplinkedHomeserverUnavailable(viewEvents)
} }
} }
private fun onDeeplinkedHomeserverUnavailable(viewEvents: OnboardingViewEvents.DeeplinkAuthenticationFailure) {
showHomeserverUnavailableDialog(onboardingViewModel.getInitialHomeServerUrl().orEmpty()) {
onboardingViewModel.handle(OnboardingAction.ResetDeeplinkConfig)
onboardingViewModel.handle(viewEvents.retryAction)
}
}
private fun showHomeserverUnavailableDialog(url: String, action: () -> Unit) {
MaterialAlertDialogBuilder(activity)
.setTitle(R.string.dialog_title_error)
.setMessage(activity.getString(R.string.login_error_homeserver_from_url_not_found, url))
.setPositiveButton(R.string.login_error_homeserver_from_url_not_found_enter_manual) { _, _ -> action() }
.setNegativeButton(R.string.action_cancel, null)
.show()
}
private fun onStartCombinedLogin() { private fun onStartCombinedLogin() {
addRegistrationStageFragmentToBackstack(FtueAuthCombinedLoginFragment::class.java) addRegistrationStageFragmentToBackstack(FtueAuthCombinedLoginFragment::class.java)
} }

View file

@ -18,6 +18,8 @@ package im.vector.app.features.onboarding
import android.net.Uri import android.net.Uri
import com.airbnb.mvrx.test.MvRxTestRule import com.airbnb.mvrx.test.MvRxTestRule
import im.vector.app.R
import im.vector.app.features.login.LoginConfig
import im.vector.app.features.login.LoginMode import im.vector.app.features.login.LoginMode
import im.vector.app.features.login.ReAuthHelper import im.vector.app.features.login.ReAuthHelper
import im.vector.app.features.login.SignMode import im.vector.app.features.login.SignMode
@ -38,6 +40,8 @@ import im.vector.app.test.fakes.FakeUri
import im.vector.app.test.fakes.FakeUriFilenameResolver import im.vector.app.test.fakes.FakeUriFilenameResolver
import im.vector.app.test.fakes.FakeVectorFeatures import im.vector.app.test.fakes.FakeVectorFeatures
import im.vector.app.test.fakes.FakeVectorOverrides import im.vector.app.test.fakes.FakeVectorOverrides
import im.vector.app.test.fakes.toTestString
import im.vector.app.test.fixtures.aBuildMeta
import im.vector.app.test.fixtures.aHomeServerCapabilities import im.vector.app.test.fixtures.aHomeServerCapabilities
import im.vector.app.test.test import im.vector.app.test.test
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -242,6 +246,27 @@ class OnboardingViewModelTest {
.finish() .finish()
} }
@Test
fun `given unavailable deeplink, when selecting homeserver, then emits failure with default homeserver as retry action`() = runTest {
fakeContext.givenHasConnection()
fakeHomeServerConnectionConfigFactory.givenConfigFor(A_HOMESERVER_URL, A_HOMESERVER_CONFIG)
fakeStartAuthenticationFlowUseCase.givenHomeserverUnavailable(A_HOMESERVER_CONFIG)
val test = viewModel.test()
viewModel.handle(OnboardingAction.InitWith(LoginConfig(A_HOMESERVER_URL, null)))
viewModel.handle(OnboardingAction.HomeServerChange.SelectHomeServer(A_HOMESERVER_URL))
val expectedRetryAction = OnboardingAction.HomeServerChange.SelectHomeServer("${R.string.matrix_org_server_url.toTestString()}/")
test
.assertStatesChanges(
initialState,
{ copy(isLoading = true) },
{ copy(isLoading = false) }
)
.assertEvents(OnboardingViewEvents.DeeplinkAuthenticationFailure(expectedRetryAction))
.finish()
}
@Test @Test
fun `given in the sign up flow, when editing homeserver, then updates selected homeserver state and emits edited event`() = runTest { fun `given in the sign up flow, when editing homeserver, then updates selected homeserver state and emits edited event`() = runTest {
viewModelWith(initialState.copy(onboardingFlow = OnboardingFlow.SignUp)) viewModelWith(initialState.copy(onboardingFlow = OnboardingFlow.SignUp))
@ -457,7 +482,8 @@ class OnboardingViewModelTest {
fakeRegisterActionHandler.instance, fakeRegisterActionHandler.instance,
fakeDirectLoginUseCase.instance, fakeDirectLoginUseCase.instance,
fakeStartAuthenticationFlowUseCase.instance, fakeStartAuthenticationFlowUseCase.instance,
FakeVectorOverrides() FakeVectorOverrides(),
aBuildMeta()
).also { ).also {
viewModel = it viewModel = it
initialState = state initialState = state

View file

@ -0,0 +1,44 @@
/*
* 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 android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import io.mockk.every
import io.mockk.mockk
class FakeConnectivityManager {
val instance = mockk<ConnectivityManager>()
fun givenNoActiveConnection() {
every { instance.activeNetwork } returns null
}
fun givenHasActiveConnection() {
val network = mockk<Network>()
every { instance.activeNetwork } returns network
val networkCapabilities = FakeNetworkCapabilities()
networkCapabilities.givenTransports(
NetworkCapabilities.TRANSPORT_CELLULAR,
NetworkCapabilities.TRANSPORT_WIFI,
NetworkCapabilities.TRANSPORT_VPN
)
every { instance.getNetworkCapabilities(network) } returns networkCapabilities.instance
}
}

View file

@ -18,6 +18,7 @@ package im.vector.app.test.fakes
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.net.ConnectivityManager
import android.net.Uri import android.net.Uri
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import io.mockk.every import io.mockk.every
@ -48,4 +49,21 @@ class FakeContext(
fun givenMissingSafeOutputStreamFor(uri: Uri) { fun givenMissingSafeOutputStreamFor(uri: Uri) {
every { contentResolver.openOutputStream(uri, "wt") } returns null every { contentResolver.openOutputStream(uri, "wt") } returns null
} }
fun givenNoConnection() {
val connectivityManager = FakeConnectivityManager()
connectivityManager.givenNoActiveConnection()
givenService(Context.CONNECTIVITY_SERVICE, ConnectivityManager::class.java, connectivityManager.instance)
}
private fun <T> givenService(name: String, klass: Class<T>, service: T) {
every { instance.getSystemService(name) } returns service
every { instance.getSystemService(klass) } returns service
}
fun givenHasConnection() {
val connectivityManager = FakeConnectivityManager()
connectivityManager.givenHasActiveConnection()
givenService(Context.CONNECTIVITY_SERVICE, ConnectivityManager::class.java, connectivityManager.instance)
}
} }

View file

@ -0,0 +1,32 @@
/*
* 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 android.net.NetworkCapabilities
import io.mockk.every
import io.mockk.mockk
class FakeNetworkCapabilities {
val instance = mockk<NetworkCapabilities>()
fun givenTransports(vararg type: Int) {
every { instance.hasTransport(any()) } answers {
val input = it.invocation.args.first() as Int
type.contains(input)
}
}
}

View file

@ -18,6 +18,7 @@ package im.vector.app.test.fakes
import im.vector.app.features.onboarding.StartAuthenticationFlowUseCase import im.vector.app.features.onboarding.StartAuthenticationFlowUseCase
import im.vector.app.features.onboarding.StartAuthenticationFlowUseCase.StartAuthenticationResult import im.vector.app.features.onboarding.StartAuthenticationFlowUseCase.StartAuthenticationResult
import im.vector.app.test.fixtures.aHomeserverUnavailableError
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.mockk import io.mockk.mockk
import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
@ -29,4 +30,8 @@ class FakeStartAuthenticationFlowUseCase {
fun givenResult(config: HomeServerConnectionConfig, result: StartAuthenticationResult) { fun givenResult(config: HomeServerConnectionConfig, result: StartAuthenticationResult) {
coEvery { instance.execute(config) } returns result coEvery { instance.execute(config) } returns result
} }
fun givenHomeserverUnavailable(config: HomeServerConnectionConfig) {
coEvery { instance.execute(config) } throws aHomeserverUnavailableError()
}
} }

View file

@ -0,0 +1,22 @@
/*
* 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 android.os.Build
import im.vector.app.core.resources.BuildMeta
fun aBuildMeta() = BuildMeta(Build.VERSION_CODES.O)

View file

@ -18,8 +18,11 @@ package im.vector.app.test.fixtures
import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.failure.MatrixError import org.matrix.android.sdk.api.failure.MatrixError
import java.net.UnknownHostException
import javax.net.ssl.HttpsURLConnection import javax.net.ssl.HttpsURLConnection
fun a401ServerError() = Failure.ServerError( fun a401ServerError() = Failure.ServerError(
MatrixError(MatrixError.M_UNAUTHORIZED, ""), HttpsURLConnection.HTTP_UNAUTHORIZED MatrixError(MatrixError.M_UNAUTHORIZED, ""), HttpsURLConnection.HTTP_UNAUTHORIZED
) )
fun aHomeserverUnavailableError() = Failure.NetworkConnection(UnknownHostException())