diff --git a/authenticator/api/authenticator.api b/authenticator/api/authenticator.api index 80d20739..6534d4f1 100644 --- a/authenticator/api/authenticator.api +++ b/authenticator/api/authenticator.api @@ -22,15 +22,18 @@ public final class com/amplifyframework/ui/authenticator/BuildConfig { public final class com/amplifyframework/ui/authenticator/ErrorState : com/amplifyframework/ui/authenticator/AuthenticatorStepState { public static final field $stable I - public fun (Lcom/amplifyframework/auth/AuthException;)V + public fun (Lcom/amplifyframework/auth/AuthException;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Lcom/amplifyframework/auth/AuthException;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lcom/amplifyframework/auth/AuthException; - public final fun copy (Lcom/amplifyframework/auth/AuthException;)Lcom/amplifyframework/ui/authenticator/ErrorState; - public static synthetic fun copy$default (Lcom/amplifyframework/ui/authenticator/ErrorState;Lcom/amplifyframework/auth/AuthException;ILjava/lang/Object;)Lcom/amplifyframework/ui/authenticator/ErrorState; + public final fun copy (Lcom/amplifyframework/auth/AuthException;Lkotlin/jvm/functions/Function1;)Lcom/amplifyframework/ui/authenticator/ErrorState; + public static synthetic fun copy$default (Lcom/amplifyframework/ui/authenticator/ErrorState;Lcom/amplifyframework/auth/AuthException;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/amplifyframework/ui/authenticator/ErrorState; public fun equals (Ljava/lang/Object;)Z + public final fun getCanRetry ()Z public final fun getError ()Lcom/amplifyframework/auth/AuthException; public fun getStep ()Lcom/amplifyframework/ui/authenticator/enums/AuthenticatorStep$Error; public synthetic fun getStep ()Lcom/amplifyframework/ui/authenticator/enums/AuthenticatorStep; public fun hashCode ()I + public final fun retry (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun toString ()Ljava/lang/String; } @@ -674,6 +677,13 @@ public final class com/amplifyframework/ui/authenticator/ui/AuthenticatorLoading public static final fun AuthenticatorLoading (Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V } +public final class com/amplifyframework/ui/authenticator/ui/ComposableSingletons$AuthenticatorErrorKt { + public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/ui/ComposableSingletons$AuthenticatorErrorKt; + public static field lambda-1 Lkotlin/jvm/functions/Function3; + public fun ()V + public final fun getLambda-1$authenticator_release ()Lkotlin/jvm/functions/Function3; +} + public final class com/amplifyframework/ui/authenticator/ui/ComposableSingletons$AuthenticatorFormKt { public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/ui/ComposableSingletons$AuthenticatorFormKt; public static field lambda-1 Lkotlin/jvm/functions/Function2; diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorStepState.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorStepState.kt index 483dc409..e63a4a3d 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorStepState.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorStepState.kt @@ -51,8 +51,14 @@ object LoadingState : AuthenticatorStepState { * @param error The error that occurred. */ @Immutable -data class ErrorState(val error: AuthException) : AuthenticatorStepState { +data class ErrorState( + val error: AuthException, + private val onRetry: (suspend () -> Unit)? = null +) : AuthenticatorStepState { override val step = AuthenticatorStep.Error + val canRetry = onRetry != null + + suspend fun retry() = onRetry?.invoke() } /** diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorViewModel.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorViewModel.kt index 67673b95..9ce24168 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorViewModel.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorViewModel.kt @@ -90,10 +90,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jetbrains.annotations.VisibleForTesting -internal class AuthenticatorViewModel( - application: Application, - private val authProvider: AuthProvider -) : AndroidViewModel(application) { +internal class AuthenticatorViewModel(application: Application, private val authProvider: AuthProvider) : + AndroidViewModel(application) { // Constructor for compose viewModels provider constructor(application: Application) : this(application, RealAuthProvider()) @@ -148,13 +146,7 @@ internal class AuthenticatorViewModel( ::moveTo ) - // Fetch the current session to determine if the user is already authenticated - val result = authProvider.fetchAuthSession() - when { - result is AmplifyResult.Error -> handleGeneralFailure(result.error) - result is AmplifyResult.Success && result.data.isSignedIn -> handleSignedIn() - else -> moveTo(configuration.initialStep) - } + checkInitialLogin() } // Respond to any events from Amplify Auth @@ -168,6 +160,20 @@ internal class AuthenticatorViewModel( } } + private suspend fun checkInitialLogin() { + // Fetch the current session to determine if the user is already authenticated + val result = authProvider.fetchAuthSession() + when { + // Allow user to retry a failure from fetchAuthSession + result is AmplifyResult.Error -> handleRetryableGeneralFailure( + error = result.error, + onRetry = { viewModelScope.launch { checkInitialLogin() }.join() } + ) + result is AmplifyResult.Success && result.data.isSignedIn -> handleSignedIn() + else -> moveTo(configuration.initialStep) + } + } + fun moveTo(initialStep: AuthenticatorInitialStep) { logger.debug("Moving to initial step: $initialStep") val state = when (initialStep) { @@ -355,10 +361,7 @@ internal class AuthenticatorViewModel( ) } - private suspend fun handleEmailMfaSetupRequired( - username: String, - password: String - ) { + private suspend fun handleEmailMfaSetupRequired(username: String, password: String) { moveTo( stateFactory.newSignInContinueWithEmailSetupState( onSubmit = { mfaType -> confirmSignIn(username, password, mfaType) } @@ -366,11 +369,7 @@ internal class AuthenticatorViewModel( ) } - private suspend fun handleMfaSelectionRequired( - username: String, - password: String, - allowedMfaTypes: Set? - ) { + private suspend fun handleMfaSelectionRequired(username: String, password: String, allowedMfaTypes: Set?) { if (allowedMfaTypes.isNullOrEmpty()) { handleGeneralFailure(AuthException("Missing allowedMfaTypes", "Please open a bug with Amplify")) return @@ -492,10 +491,7 @@ internal class AuthenticatorViewModel( }.join() } - private suspend fun handleResetPasswordSuccess( - username: String, - result: AuthResetPasswordResult - ) { + private suspend fun handleResetPasswordSuccess(username: String, result: AuthResetPasswordResult) { when (result.nextStep.resetPasswordStep) { AuthResetPasswordStep.DONE -> handlePasswordResetComplete() AuthResetPasswordStep.CONFIRM_RESET_PASSWORD_WITH_CODE -> { @@ -626,7 +622,10 @@ internal class AuthenticatorViewModel( logger.error("Current signed in user session has expired, signing out.") signOut() } else { - handleGeneralFailure(result.error) + handleRetryableGeneralFailure( + error = result.error, + onRetry = { viewModelScope.launch { handleSignedIn() }.join() } + ) } } @@ -649,6 +648,11 @@ internal class AuthenticatorViewModel( moveTo(ErrorState(error)) } + private fun handleRetryableGeneralFailure(error: AuthException, onRetry: suspend () -> Unit) { + logger.error(error.toString()) + moveTo(ErrorState(error, onRetry)) + } + private suspend fun sendMessage(event: AuthenticatorMessage) { logger.debug("Sending message: $event") _events.emit(event) diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/Authenticator.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/Authenticator.kt index 1abe05da..04b65f03 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/Authenticator.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/Authenticator.kt @@ -134,6 +134,7 @@ fun Authenticator( Box(modifier = modifier) { AnimatedContent( targetState = stepState, + contentKey = { targetState -> targetState::class }, transitionSpec = { defaultTransition() }, label = "AuthenticatorContentTransition" ) { targetState -> diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/AuthenticatorError.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/AuthenticatorError.kt index cd86daad..bbf55593 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/AuthenticatorError.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/AuthenticatorError.kt @@ -15,39 +15,65 @@ package com.amplifyframework.ui.authenticator.ui +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.amplifyframework.ui.authenticator.ErrorState +import com.amplifyframework.ui.authenticator.R import com.amplifyframework.ui.authenticator.strings.StringResolver +import kotlinx.coroutines.launch /** * The content displayed when Authenticator is in the ErrorState */ @Composable -fun AuthenticatorError( - state: ErrorState, - modifier: Modifier = Modifier -) { - Box( - modifier = modifier - .fillMaxWidth() - .padding(16.dp) - .background(MaterialTheme.colorScheme.errorContainer) - .padding(16.dp), - contentAlignment = Alignment.Center - ) { - val message = StringResolver.error(state.error) - Text( - text = message, - color = MaterialTheme.colorScheme.onErrorContainer - ) +fun AuthenticatorError(state: ErrorState, modifier: Modifier = Modifier) { + val scope = rememberCoroutineScope() + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .background(MaterialTheme.colorScheme.errorContainer) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + val message = StringResolver.error(state.error) + Text( + text = message, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + AnimatedVisibility(state.canRetry) { + var retrying by remember { mutableStateOf(false) } + TextButton( + onClick = { + scope.launch { + retrying = true + state.retry() + retrying = false + } + }, + enabled = !retrying + ) { + Text(stringResource(R.string.amplify_ui_authenticator_button_retry)) + } + } } } diff --git a/authenticator/src/main/res/values/buttons.xml b/authenticator/src/main/res/values/buttons.xml index 2e654d2d..8e69fd92 100644 --- a/authenticator/src/main/res/values/buttons.xml +++ b/authenticator/src/main/res/values/buttons.xml @@ -26,4 +26,5 @@ Send Code Skip Copy Key + Retry diff --git a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/AuthenticatorViewModelTest.kt b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/AuthenticatorViewModelTest.kt index e77cec91..f97f7976 100644 --- a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/AuthenticatorViewModelTest.kt +++ b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/AuthenticatorViewModelTest.kt @@ -29,7 +29,6 @@ import com.amplifyframework.auth.result.step.AuthResetPasswordStep import com.amplifyframework.auth.result.step.AuthSignInStep import com.amplifyframework.ui.authenticator.auth.VerificationMechanism import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep -import com.amplifyframework.ui.authenticator.util.AmplifyResult import com.amplifyframework.ui.authenticator.util.AmplifyResult.Error import com.amplifyframework.ui.authenticator.util.AmplifyResult.Success import com.amplifyframework.ui.authenticator.util.AuthConfigurationResult @@ -38,6 +37,7 @@ import com.amplifyframework.ui.authenticator.util.LimitExceededMessage import com.amplifyframework.ui.authenticator.util.NetworkErrorMessage import com.amplifyframework.ui.testing.CoroutineTestRule import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -108,7 +108,7 @@ class AuthenticatorViewModelTest { @Test fun `fetchAuthSession error during start results in an error`() = runTest { - coEvery { authProvider.fetchAuthSession() } returns AmplifyResult.Error(mockAuthException()) + coEvery { authProvider.fetchAuthSession() } returns Error(mockAuthException()) viewModel.start(mockAuthenticatorConfiguration()) advanceUntilIdle() @@ -117,10 +117,28 @@ class AuthenticatorViewModelTest { viewModel.currentStep shouldBe AuthenticatorStep.Error } + @Test + fun `fetchAuthSession error can be retried`() = runTest { + coEvery { authProvider.fetchAuthSession() } returns + Error(mockAuthException()) andThen Success(mockAuthSession()) + + viewModel.start(mockAuthenticatorConfiguration()) + advanceUntilIdle() + + val state = viewModel.stepState.value.shouldBeInstanceOf() + state.retry() + advanceUntilIdle() + + viewModel.currentStep shouldBe AuthenticatorStep.SignIn + coVerify(exactly = 2) { + authProvider.fetchAuthSession() + } + } + @Test fun `getCurrentUser error during start results in an error`() = runTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = true)) - coEvery { authProvider.getCurrentUser() } returns AmplifyResult.Error(mockAuthException()) + coEvery { authProvider.getCurrentUser() } returns Error(mockAuthException()) viewModel.start(mockAuthenticatorConfiguration()) advanceUntilIdle() @@ -135,7 +153,7 @@ class AuthenticatorViewModelTest { @Test fun `getCurrentUser error with session expired exception during start results in being signed out`() = runTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = true)) - coEvery { authProvider.getCurrentUser() } returns AmplifyResult.Error(SessionExpiredException()) + coEvery { authProvider.getCurrentUser() } returns Error(SessionExpiredException()) viewModel.start(mockAuthenticatorConfiguration()) advanceUntilIdle() @@ -147,6 +165,27 @@ class AuthenticatorViewModelTest { } } + @Test + fun `getCurrentUser error can be retried`() = runTest { + coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = true)) + coEvery { authProvider.getCurrentUser() } returns Error(mockAuthException()) andThen Success(mockAuthUser()) + + viewModel.start(mockAuthenticatorConfiguration()) + advanceUntilIdle() + + val state = viewModel.stepState.value.shouldBeInstanceOf() + state.retry() + advanceUntilIdle() + + viewModel.currentStep shouldBe AuthenticatorStep.SignedIn + coVerify(exactly = 1) { + authProvider.fetchAuthSession() + } + coVerify(exactly = 2) { + authProvider.getCurrentUser() + } + } + @Test fun `when already signed in during start the initial state should be signed in`() = runTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = true)) @@ -265,7 +304,7 @@ class AuthenticatorViewModelTest { coEvery { authProvider.signIn(any(), any()) } returns Success( mockSignInResult(signInStep = AuthSignInStep.CONFIRM_SIGN_UP) ) - coEvery { authProvider.resendSignUpCode(any()) } returns AmplifyResult.Error(mockAuthException()) + coEvery { authProvider.resendSignUpCode(any()) } returns Error(mockAuthException()) viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.SignIn)) @@ -394,7 +433,7 @@ class AuthenticatorViewModelTest { verificationMechanisms = setOf(VerificationMechanism.Email) ) // cannot fetch user attributes - coEvery { authProvider.fetchUserAttributes() } returns AmplifyResult.Error(mockk(relaxed = true)) + coEvery { authProvider.fetchUserAttributes() } returns Error(mockk(relaxed = true)) viewModel.start(mockAuthenticatorConfiguration()) viewModel.signIn("username", "password") @@ -571,6 +610,7 @@ class AuthenticatorViewModelTest { viewModel.resetPassword("username") } } + //endregion //region helpers private val AuthenticatorViewModel.currentStep: AuthenticatorStep