diff --git a/changelog.d/6023.wip b/changelog.d/6023.wip new file mode 100644 index 0000000000..aefd62bcd7 --- /dev/null +++ b/changelog.d/6023.wip @@ -0,0 +1 @@ +FTUE - Adds homeserver login/register deeplink support diff --git a/vector/src/main/java/im/vector/app/core/di/SingletonModule.kt b/vector/src/main/java/im/vector/app/core/di/SingletonModule.kt index 2945ae7d87..44f8bb1b3e 100644 --- a/vector/src/main/java/im/vector/app/core/di/SingletonModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/SingletonModule.kt @@ -33,6 +33,7 @@ import im.vector.app.config.analyticsConfig import im.vector.app.core.dispatchers.CoroutineDispatchers import im.vector.app.core.error.DefaultErrorFormatter 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.DefaultClock import im.vector.app.features.analytics.AnalyticsConfig @@ -185,4 +186,8 @@ object VectorStaticModule { fun providesAnalyticsConfig(): AnalyticsConfig { return analyticsConfig } + + @Provides + @Singleton + fun providesBuildMeta() = BuildMeta() } diff --git a/vector/src/main/java/im/vector/app/core/extensions/Context.kt b/vector/src/main/java/im/vector/app/core/extensions/Context.kt index 0f785e43a3..81844a403b 100644 --- a/vector/src/main/java/im/vector/app/core/extensions/Context.kt +++ b/vector/src/main/java/im/vector/app/core/extensions/Context.kt @@ -16,9 +16,13 @@ package im.vector.app.core.extensions +import android.annotation.SuppressLint import android.content.Context import android.graphics.drawable.Drawable +import android.net.ConnectivityManager +import android.net.NetworkCapabilities import android.net.Uri +import android.os.Build import android.text.Spannable import android.text.SpannableString import android.text.style.ImageSpan @@ -27,11 +31,13 @@ import androidx.annotation.ColorRes import androidx.annotation.DrawableRes import androidx.annotation.FloatRange import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import dagger.hilt.EntryPoints import im.vector.app.core.datastore.dataStoreProvider import im.vector.app.core.di.SingletonEntryPoint +import im.vector.app.core.resources.BuildMeta import java.io.OutputStream import kotlin.math.roundToInt @@ -77,3 +83,31 @@ val Context.dataStoreProvider: (String) -> DataStore by dataStorePr fun Context.safeOpenOutputStream(uri: Uri): OutputStream? { 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()!! + 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 + } + } +} diff --git a/vector/src/main/java/im/vector/app/core/resources/BuildMeta.kt b/vector/src/main/java/im/vector/app/core/resources/BuildMeta.kt new file mode 100644 index 0000000000..14d97e4c8f --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/resources/BuildMeta.kt @@ -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 +) diff --git a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingAction.kt b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingAction.kt index bef624ddc4..bd2ff1a26a 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingAction.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingAction.kt @@ -25,8 +25,12 @@ import org.matrix.android.sdk.api.auth.data.Credentials import org.matrix.android.sdk.api.network.ssl.Fingerprint sealed interface OnboardingAction : VectorViewModelAction { - data class OnGetStarted(val resetLoginConfig: Boolean, val onboardingFlow: OnboardingFlow) : OnboardingAction - data class OnIAlreadyHaveAnAccount(val resetLoginConfig: Boolean, val onboardingFlow: OnboardingFlow) : OnboardingAction + sealed interface SplashAction : 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 @@ -58,7 +62,7 @@ sealed interface OnboardingAction : VectorViewModelAction { // Reset actions sealed interface ResetAction : OnboardingAction - + object ResetDeeplinkConfig : ResetAction object ResetHomeServerType : ResetAction object ResetHomeServerUrl : ResetAction object ResetSignMode : ResetAction diff --git a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewEvents.kt b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewEvents.kt index 5dbcd162f3..5d6e7005c4 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewEvents.kt @@ -28,6 +28,7 @@ import org.matrix.android.sdk.api.auth.registration.FlowResult sealed class OnboardingViewEvents : VectorViewEvents { data class Loading(val message: CharSequence? = null) : 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() object OutdatedHomeserver : OnboardingViewEvents() diff --git a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt index a1527c9ecf..b1fbf45f2b 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt @@ -27,9 +27,12 @@ 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.inferNoConnectivity import im.vector.app.core.extensions.vectorStore 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.utils.ensureProtocol import im.vector.app.core.utils.ensureTrailingSlash import im.vector.app.features.VectorFeatures 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.RegistrationWizard 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 timber.log.Timber import java.util.UUID @@ -78,7 +82,8 @@ class OnboardingViewModel @AssistedInject constructor( private val registrationActionHandler: RegistrationActionHandler, private val directLoginUseCase: DirectLoginUseCase, private val startAuthenticationFlowUseCase: StartAuthenticationFlowUseCase, - private val vectorOverrides: VectorOverrides + private val vectorOverrides: VectorOverrides, + private val buildMeta: BuildMeta ) : VectorViewModel(initialState) { @AssistedFactory @@ -132,8 +137,7 @@ class OnboardingViewModel @AssistedInject constructor( override fun handle(action: OnboardingAction) { when (action) { - is OnboardingAction.OnGetStarted -> handleSplashAction(action.resetLoginConfig, action.onboardingFlow) - is OnboardingAction.OnIAlreadyHaveAnAccount -> handleSplashAction(action.resetLoginConfig, action.onboardingFlow) + is OnboardingAction.SplashAction -> handleSplashAction(action) is OnboardingAction.UpdateUseCase -> handleUpdateUseCase(action) OnboardingAction.ResetUseCase -> resetUseCase() is OnboardingAction.UpdateServerType -> handleUpdateServerType(action) @@ -173,26 +177,9 @@ class OnboardingViewModel @AssistedInject constructor( } } - private fun handleSplashAction(resetConfig: Boolean, onboardingFlow: OnboardingFlow) { - if (resetConfig) { - loginConfig = null - } - 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 handleSplashAction(action: OnboardingAction.SplashAction) { + setState { copy(onboardingFlow = action.onboardingFlow) } + continueToPageAfterSplash(action.onboardingFlow) } private fun continueToPageAfterSplash(onboardingFlow: OnboardingFlow) { @@ -206,10 +193,21 @@ class OnboardingViewModel @AssistedInject constructor( } ) } - OnboardingFlow.SignIn -> if (vectorFeatures.isOnboardingCombinedLoginEnabled()) { - handle(OnboardingAction.HomeServerChange.SelectHomeServer(defaultHomeserverUrl)) - } else _viewEvents.post(OnboardingViewEvents.OpenServerSelection) - OnboardingFlow.SignInSignUp -> _viewEvents.post(OnboardingViewEvents.OpenServerSelection) + OnboardingFlow.SignIn -> when { + vectorFeatures.isOnboardingCombinedLoginEnabled() -> { + handle(OnboardingAction.HomeServerChange.SelectHomeServer(deeplinkOrDefaultHomeserverUrl())) + } + 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 -> { currentHomeServerConnectionConfig ?.let { it.copy(allowedFingerprints = it.allowedFingerprints + action.fingerprint) } - ?.let { startAuthenticationFlow(finalLastAction, it) } + ?.let { startAuthenticationFlow(finalLastAction, it, serverTypeOverride = null) } } is AuthenticateAction.LoginDirect -> 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) { setState { copy(useCase = action.useCase) } when (vectorFeatures.isOnboardingCombinedRegisterEnabled()) { - true -> handle(OnboardingAction.HomeServerChange.SelectHomeServer(defaultHomeserverUrl)) + true -> handle(OnboardingAction.HomeServerChange.SelectHomeServer(deeplinkOrDefaultHomeserverUrl())) false -> _viewEvents.post(OnboardingViewEvents.OpenServerSelection) } } + private fun deeplinkOrDefaultHomeserverUrl() = loginConfig?.homeServerUrl?.ensureProtocol() ?: defaultHomeserverUrl + private fun resetUseCase() { setState { copy(useCase = null) } } @@ -422,7 +423,6 @@ class OnboardingViewModel @AssistedInject constructor( private fun handleInitWith(action: OnboardingAction.InitWith) { loginConfig = action.loginConfig - // If there is a pending email validation continue on this step try { 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) if (homeServerConnectionConfig == null) { // This is invalid _viewEvents.post(OnboardingViewEvents.Failure(Throwable("Unable to create a HomeServerConnectionConfig"))) } else { - startAuthenticationFlow(action, homeServerConnectionConfig) + startAuthenticationFlow(action, homeServerConnectionConfig, serverTypeOverride) } } private fun startAuthenticationFlow( - trigger: OnboardingAction?, + trigger: OnboardingAction.HomeServerChange, homeServerConnectionConfig: HomeServerConnectionConfig, - serverTypeOverride: ServerType? = null + serverTypeOverride: ServerType? ) { currentHomeServerConnectionConfig = homeServerConnectionConfig @@ -632,14 +632,36 @@ class OnboardingViewModel @AssistedInject constructor( setState { copy(isLoading = true) } runCatching { startAuthenticationFlowUseCase.execute(homeServerConnectionConfig) }.fold( onSuccess = { onAuthenticationStartedSuccess(trigger, homeServerConnectionConfig, it, serverTypeOverride) }, - onFailure = { _viewEvents.post(OnboardingViewEvents.Failure(it)) } + onFailure = { onAuthenticationStartError(it, trigger) } ) 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( - trigger: OnboardingAction?, + trigger: OnboardingAction.HomeServerChange, config: HomeServerConnectionConfig, authResult: StartAuthenticationResult, serverTypeOverride: ServerType? @@ -650,47 +672,51 @@ class OnboardingViewModel @AssistedInject constructor( } 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 -> { - updateServerSelection(config, serverTypeOverride, authResult) - if (authResult.selectedHomeserver.preferredLoginMode.supportsSignModeScreen()) { - when (awaitState().onboardingFlow) { - OnboardingFlow.SignIn -> { - updateSignMode(SignMode.SignIn) - when (vectorFeatures.isOnboardingCombinedLoginEnabled()) { - true -> _viewEvents.post(OnboardingViewEvents.OpenCombinedLogin) - false -> _viewEvents.post(OnboardingViewEvents.OnSignModeSelected(SignMode.SignIn)) - } - } - OnboardingFlow.SignUp -> { - updateSignMode(SignMode.SignUp) - internalRegisterAction(RegisterAction.StartRegistration, ::emitFlowResultViewEvent) - } - OnboardingFlow.SignInSignUp, - null -> { - _viewEvents.post(OnboardingViewEvents.OnLoginFlowRetrieved) - } + onHomeServerSelected(config, serverTypeOverride, authResult) + } + is OnboardingAction.HomeServerChange.EditHomeServer -> { + onHomeServerEdited(config, serverTypeOverride, authResult) + } + } + } + + private suspend fun onHomeServerSelected(config: HomeServerConnectionConfig, serverTypeOverride: ServerType?, authResult: StartAuthenticationResult) { + updateServerSelection(config, serverTypeOverride, authResult) + if (authResult.selectedHomeserver.preferredLoginMode.supportsSignModeScreen()) { + when (awaitState().onboardingFlow) { + OnboardingFlow.SignIn -> { + updateSignMode(SignMode.SignIn) + when (vectorFeatures.isOnboardingCombinedLoginEnabled()) { + true -> _viewEvents.post(OnboardingViewEvents.OpenCombinedLogin) + false -> _viewEvents.post(OnboardingViewEvents.OnSignModeSelected(SignMode.SignIn)) } - } else { + } + OnboardingFlow.SignUp -> { + updateSignMode(SignMode.SignUp) + internalRegisterAction(RegisterAction.StartRegistration, ::emitFlowResultViewEvent) + } + OnboardingFlow.SignInSignUp, + null -> { _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) - _viewEvents.post(OnboardingViewEvents.OnLoginFlowRetrieved) + _viewEvents.post(OnboardingViewEvents.OnHomeserverEdited) } + OnboardingFlow.SignIn -> { + updateServerSelection(config, serverTypeOverride, authResult) + _viewEvents.post(OnboardingViewEvents.OnHomeserverEdited) + } + else -> throw IllegalArgumentException("developer error") } } diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSplashCarouselFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSplashCarouselFragment.kt index 30416bde9e..0d86c4cd24 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSplashCarouselFragment.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSplashCarouselFragment.kt @@ -26,8 +26,6 @@ import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope 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 im.vector.app.BuildConfig import im.vector.app.R @@ -41,8 +39,6 @@ import im.vector.app.features.settings.VectorPreferences import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import org.matrix.android.sdk.api.failure.Failure -import java.net.UnknownHostException import javax.inject.Inject private const val CAROUSEL_ROTATION_DELAY_MS = 5000L @@ -128,33 +124,14 @@ class FtueAuthSplashCarouselFragment @Inject constructor( private fun splashSubmit(isAlreadyHaveAccountEnabled: Boolean) { 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() { - viewModel.handle(OnboardingAction.OnIAlreadyHaveAnAccount(resetLoginConfig = false, onboardingFlow = OnboardingFlow.SignIn)) + viewModel.handle(OnboardingAction.SplashAction.OnIAlreadyHaveAnAccount(onboardingFlow = OnboardingFlow.SignIn)) } override fun resetViewModel() { // Nothing to do } - - override fun onError(throwable: Throwable) { - if (throwable is Failure.NetworkConnection && - throwable.ioException is UnknownHostException) { - // Invalid homeserver from URL config - val url = 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) - } - } } diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSplashFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSplashFragment.kt index 2fa3b52706..cd1e4b2714 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSplashFragment.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSplashFragment.kt @@ -22,8 +22,6 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup 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.R 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.OnboardingFlow import im.vector.app.features.settings.VectorPreferences -import org.matrix.android.sdk.api.failure.Failure -import java.net.UnknownHostException import javax.inject.Inject /** @@ -75,33 +71,14 @@ class FtueAuthSplashFragment @Inject constructor( private fun splashSubmit(isAlreadyHaveAccountEnabled: Boolean) { 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() { - viewModel.handle(OnboardingAction.OnIAlreadyHaveAnAccount(resetLoginConfig = false, onboardingFlow = OnboardingFlow.SignIn)) + viewModel.handle(OnboardingAction.SplashAction.OnIAlreadyHaveAnAccount(onboardingFlow = OnboardingFlow.SignIn)) } override fun resetViewModel() { // Nothing to do } - - override fun onError(throwable: Throwable) { - if (throwable is Failure.NetworkConnection && - throwable.ioException is UnknownHostException) { - // Invalid homeserver from URL config - val url = 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) - } - } } diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthUseCaseFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthUseCaseFragment.kt index 5325b25e93..35439a794e 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthUseCaseFragment.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthUseCaseFragment.kt @@ -44,7 +44,7 @@ private const val DARK_MODE_ICON_BACKGROUND_ALPHA = 0.30f private const val LIGHT_MODE_ICON_BACKGROUND_ALPHA = 0.15f class FtueAuthUseCaseFragment @Inject constructor( - private val themeProvider: ThemeProvider + private val themeProvider: ThemeProvider, ) : AbstractFtueAuthFragment() { 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 { val context = requireContext() val alpha = when (isLightMode) { - true -> LIGHT_MODE_ICON_BACKGROUND_ALPHA + true -> LIGHT_MODE_ICON_BACKGROUND_ALPHA false -> DARK_MODE_ICON_BACKGROUND_ALPHA } val iconBackground = context.getResTintedDrawable(R.drawable.bg_feature_icon, tint, alpha = alpha) diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthVariant.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthVariant.kt index 5ad6b7e78d..7a3729ac69 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthVariant.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthVariant.kt @@ -227,11 +227,28 @@ class FtueAuthVariant( option = commonOption ) } - OnboardingViewEvents.OnHomeserverEdited -> activity.popBackstack() - OnboardingViewEvents.OpenCombinedLogin -> onStartCombinedLogin() + OnboardingViewEvents.OnHomeserverEdited -> activity.popBackstack() + 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() { addRegistrationStageFragmentToBackstack(FtueAuthCombinedLoginFragment::class.java) } diff --git a/vector/src/test/java/im/vector/app/features/onboarding/OnboardingViewModelTest.kt b/vector/src/test/java/im/vector/app/features/onboarding/OnboardingViewModelTest.kt index e4e687536c..1abfa7e9a8 100644 --- a/vector/src/test/java/im/vector/app/features/onboarding/OnboardingViewModelTest.kt +++ b/vector/src/test/java/im/vector/app/features/onboarding/OnboardingViewModelTest.kt @@ -18,6 +18,8 @@ package im.vector.app.features.onboarding import android.net.Uri 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.ReAuthHelper 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.FakeVectorFeatures 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.test import kotlinx.coroutines.test.runTest @@ -242,6 +246,27 @@ class OnboardingViewModelTest { .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 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)) @@ -457,7 +482,8 @@ class OnboardingViewModelTest { fakeRegisterActionHandler.instance, fakeDirectLoginUseCase.instance, fakeStartAuthenticationFlowUseCase.instance, - FakeVectorOverrides() + FakeVectorOverrides(), + aBuildMeta() ).also { viewModel = it initialState = state diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeConnectivityManager.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeConnectivityManager.kt new file mode 100644 index 0000000000..d565105f81 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeConnectivityManager.kt @@ -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() + + fun givenNoActiveConnection() { + every { instance.activeNetwork } returns null + } + + fun givenHasActiveConnection() { + val network = mockk() + 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 + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeContext.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeContext.kt index 2a50c34ca3..eb491c9e0c 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeContext.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeContext.kt @@ -18,6 +18,7 @@ package im.vector.app.test.fakes import android.content.ContentResolver import android.content.Context +import android.net.ConnectivityManager import android.net.Uri import android.os.ParcelFileDescriptor import io.mockk.every @@ -48,4 +49,21 @@ class FakeContext( fun givenMissingSafeOutputStreamFor(uri: Uri) { 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 givenService(name: String, klass: Class, 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) + } } diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeNetworkCapabilities.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeNetworkCapabilities.kt new file mode 100644 index 0000000000..36add7128c --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeNetworkCapabilities.kt @@ -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() + + fun givenTransports(vararg type: Int) { + every { instance.hasTransport(any()) } answers { + val input = it.invocation.args.first() as Int + type.contains(input) + } + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeStartAuthenticationFlowUseCase.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeStartAuthenticationFlowUseCase.kt index 697de6bf25..bfbef9e565 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeStartAuthenticationFlowUseCase.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeStartAuthenticationFlowUseCase.kt @@ -18,6 +18,7 @@ package im.vector.app.test.fakes import im.vector.app.features.onboarding.StartAuthenticationFlowUseCase import im.vector.app.features.onboarding.StartAuthenticationFlowUseCase.StartAuthenticationResult +import im.vector.app.test.fixtures.aHomeserverUnavailableError import io.mockk.coEvery import io.mockk.mockk import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig @@ -29,4 +30,8 @@ class FakeStartAuthenticationFlowUseCase { fun givenResult(config: HomeServerConnectionConfig, result: StartAuthenticationResult) { coEvery { instance.execute(config) } returns result } + + fun givenHomeserverUnavailable(config: HomeServerConnectionConfig) { + coEvery { instance.execute(config) } throws aHomeserverUnavailableError() + } } diff --git a/vector/src/test/java/im/vector/app/test/fixtures/BuildMetaFixture.kt b/vector/src/test/java/im/vector/app/test/fixtures/BuildMetaFixture.kt new file mode 100644 index 0000000000..b0e6b1dd51 --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fixtures/BuildMetaFixture.kt @@ -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) diff --git a/vector/src/test/java/im/vector/app/test/fixtures/FailureFixture.kt b/vector/src/test/java/im/vector/app/test/fixtures/FailureFixture.kt index 39c139c208..9ac851ef5e 100644 --- a/vector/src/test/java/im/vector/app/test/fixtures/FailureFixture.kt +++ b/vector/src/test/java/im/vector/app/test/fixtures/FailureFixture.kt @@ -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.MatrixError +import java.net.UnknownHostException import javax.net.ssl.HttpsURLConnection fun a401ServerError() = Failure.ServerError( MatrixError(MatrixError.M_UNAUTHORIZED, ""), HttpsURLConnection.HTTP_UNAUTHORIZED ) + +fun aHomeserverUnavailableError() = Failure.NetworkConnection(UnknownHostException())